@statforge/claudestat 1.6.1 → 1.8.0

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 (63) hide show
  1. package/README.md +36 -3
  2. package/dashboard/dist/assets/AnalyticsView-DDGLDoCN.js +7 -0
  3. package/dashboard/dist/assets/HistoryView-DkPfrNrv.js +1 -0
  4. package/dashboard/dist/assets/LineChart-BOWYkkEW.js +2 -0
  5. package/dashboard/dist/assets/ProjectsView-VRoRiEL4.js +6 -0
  6. package/dashboard/dist/assets/SystemView-B2zbIxhY.js +1 -0
  7. package/dashboard/dist/assets/TopView-C2qdsy0Y.js +1 -0
  8. package/dashboard/dist/assets/index-CMhe3KaT.js +84 -0
  9. package/dashboard/dist/assets/shared-BbBtsdh1.js +1 -0
  10. package/dashboard/dist/assets/{vendor-lucide-Cym0q5l_.js → vendor-lucide-ClCW-axQ.js} +79 -64
  11. package/dashboard/dist/assets/{vendor-react-B_Jzs0gY.js → vendor-react-gHSHIE2L.js} +1 -1
  12. package/dashboard/dist/index.html +3 -3
  13. package/dist/config.d.ts +7 -0
  14. package/dist/config.js +36 -0
  15. package/dist/daemon.js +113 -9
  16. package/dist/db.d.ts +87 -2
  17. package/dist/db.js +325 -65
  18. package/dist/doctor.js +21 -3
  19. package/dist/enricher.d.ts +3 -2
  20. package/dist/enricher.js +10 -5
  21. package/dist/export.d.ts +2 -1
  22. package/dist/export.js +41 -6
  23. package/dist/index.js +406 -20
  24. package/dist/insights.d.ts +1 -0
  25. package/dist/insights.js +26 -0
  26. package/dist/install.js +28 -1
  27. package/dist/intelligence.d.ts +66 -4
  28. package/dist/intelligence.js +205 -17
  29. package/dist/logger.d.ts +6 -0
  30. package/dist/logger.js +49 -0
  31. package/dist/notifier.d.ts +15 -0
  32. package/dist/notifier.js +26 -0
  33. package/dist/paths.d.ts +23 -0
  34. package/dist/paths.js +42 -0
  35. package/dist/pricing.d.ts +2 -0
  36. package/dist/pricing.js +12 -1
  37. package/dist/routes/events.js +136 -5
  38. package/dist/routes/helpers.d.ts +5 -0
  39. package/dist/routes/helpers.js +21 -1
  40. package/dist/routes/history.js +6 -2
  41. package/dist/routes/intents.d.ts +1 -0
  42. package/dist/routes/intents.js +155 -0
  43. package/dist/routes/misc.js +150 -4
  44. package/dist/routes/opencode-reader.js +39 -3
  45. package/dist/routes/projects.js +19 -1
  46. package/dist/routes/replay.d.ts +1 -0
  47. package/dist/routes/replay.js +29 -0
  48. package/dist/routes/reports.js +7 -0
  49. package/dist/routes/top.js +8 -1
  50. package/dist/service.js +11 -0
  51. package/dist/watchers/adapter.d.ts +1 -0
  52. package/dist/watchers/claude-code.d.ts +16 -1
  53. package/dist/watchers/claude-code.js +201 -76
  54. package/dist/watchers/opencode.d.ts +1 -0
  55. package/dist/watchers/opencode.js +152 -14
  56. package/hooks/event.js +44 -26
  57. package/package.json +1 -1
  58. package/dashboard/dist/assets/AnalyticsView-5bUM3UHp.js +0 -8
  59. package/dashboard/dist/assets/HistoryView-C-AsEqos.js +0 -1
  60. package/dashboard/dist/assets/ProjectsView-D9bZBdY2.js +0 -6
  61. package/dashboard/dist/assets/SystemView-DIYDCCF3.js +0 -1
  62. package/dashboard/dist/assets/TopView-DhdLpsiA.js +0 -1
  63. package/dashboard/dist/assets/index-DgbWvj42.js +0 -84
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.processLatestForSession = exports.onCompactDetected = exports.onCostUpdate = exports.taggedSessionParents = exports.lastAgentByCwd = exports.eventsRouter = void 0;
8
+ const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const express_1 = require("express");
10
11
  const db_1 = require("../db");
@@ -19,11 +20,23 @@ const notifier_1 = require("../notifier");
19
20
  const helpers_1 = require("./helpers");
20
21
  const enricher_1 = require("../enricher");
21
22
  Object.defineProperty(exports, "processLatestForSession", { enumerable: true, get: function () { return enricher_1.processLatestForSession; } });
23
+ const claude_code_1 = require("../watchers/claude-code");
24
+ // ─── Semantic extraction debounce: 3s after last new assistant turn ───────────
25
+ const semanticDebounce = new Map();
26
+ // ─── Predictive saturation: context samples + alert cooldown per session ──────
27
+ const contextSamples = new Map();
28
+ const saturationCooldown = new Map();
29
+ const SATURATION_WARN_MINUTES = 30;
30
+ const SATURATION_COOLDOWN_MS = 15 * 60000;
31
+ const CONTEXT_SAMPLES_MAX = 20;
22
32
  // ─── Loop alert cooldown (toolName:sessionId → last alert ts) ─────────────────
23
33
  const loopAlertCooldown = new Map();
24
34
  const LOOP_ALERT_COOLDOWN_MS = 120000; // coincide con LOOP_COOLDOWN_MS en intelligence.ts
25
35
  // ─── Session cost alert: sesiones que ya recibieron notificación ───────────────
26
36
  const sessionCostAlertFired = new Set();
37
+ // ─── Memory leak bounds ────────────────────────────────────────────────────────
38
+ const AGENT_CWD_TTL_MS = 30 * 60000; // entries older than 30min can't be valid parents
39
+ const TAGGED_PARENTS_MAX = 500; // prevent unbounded growth over long daemon runs
27
40
  exports.eventsRouter = (0, express_1.Router)();
28
41
  // Skill activa por sesión — se setea tras Skill Done, se limpia en Stop.
29
42
  // Permite taggear los eventos siguientes con skill_parent para agruparlos en la UI.
@@ -55,17 +68,52 @@ function shouldFireAlert(level, pct) {
55
68
  alertCooldown.set(level, Date.now());
56
69
  return true;
57
70
  }
71
+ function extractTextPreview(transcriptPath) {
72
+ try {
73
+ const size = fs_1.default.statSync(transcriptPath).size;
74
+ const readSize = Math.min(size, 8192);
75
+ const buf = Buffer.alloc(readSize);
76
+ const fd = fs_1.default.openSync(transcriptPath, 'r');
77
+ fs_1.default.readSync(fd, buf, 0, readSize, Math.max(0, size - readSize));
78
+ fs_1.default.closeSync(fd);
79
+ const lines = buf.toString('utf8').split('\n').filter(l => l.trim());
80
+ for (let i = lines.length - 1; i >= 0; i--) {
81
+ try {
82
+ const obj = JSON.parse(lines[i]);
83
+ const data = obj.message ?? obj;
84
+ if (data.role !== 'assistant')
85
+ continue;
86
+ let text = '';
87
+ if (typeof data.content === 'string') {
88
+ text = data.content;
89
+ }
90
+ else if (Array.isArray(data.content)) {
91
+ text = data.content.find((c) => c.type === 'text')?.text ?? '';
92
+ }
93
+ text = text.replace(/\s+/g, ' ').trim();
94
+ if (!text)
95
+ continue;
96
+ return text.length > 120 ? text.slice(0, 120) + '…' : text;
97
+ }
98
+ catch { }
99
+ }
100
+ }
101
+ catch { }
102
+ return undefined;
103
+ }
58
104
  exports.eventsRouter.post('/event', (req, res) => {
59
105
  const ip = req.ip ?? '127.0.0.1';
60
106
  if ((0, rate_limiter_1.isRateLimited)(ip)) {
61
107
  res.status(429).json({ error: 'Too many requests — wait 1 minute' });
62
108
  return;
63
109
  }
64
- const { type, session_id, tool_name, tool_input, tool_response, ts, cwd, transcript_path, source } = req.body;
110
+ const { type, session_id, tool_name, tool_input, tool_response, ts, cwd, transcript_path, source: rawSource } = req.body;
65
111
  if (!session_id || !type) {
66
112
  res.status(400).json({ error: 'Missing session_id or type' });
67
113
  return;
68
114
  }
115
+ const KNOWN_SOURCES = new Set(['claude-code', 'opencode', 'codex', 'amp', 'droid', 'codebuff']);
116
+ const source = rawSource && KNOWN_SOURCES.has(rawSource) ? rawSource : 'claude-code';
69
117
  const resolvedCwd = cwd
70
118
  ?? (transcript_path ? path_1.default.dirname(transcript_path) || undefined : undefined);
71
119
  db_1.dbOps.upsertSession({ id: session_id, cwd: resolvedCwd, started_at: ts, last_event_at: ts, source });
@@ -94,13 +142,23 @@ exports.eventsRouter.post('/event', (req, res) => {
94
142
  }
95
143
  }
96
144
  else {
145
+ const stopPreview = type === 'Stop'
146
+ ? (() => {
147
+ const lam = req.body.last_assistant_message;
148
+ if (lam && typeof lam === 'string') {
149
+ const t = lam.replace(/\s+/g, ' ').trim();
150
+ return t.length > 120 ? t.slice(0, 120) + '…' : t;
151
+ }
152
+ return transcript_path ? extractTextPreview(transcript_path) : undefined;
153
+ })()
154
+ : undefined;
97
155
  db_1.dbOps.insertEvent({
98
156
  session_id, type,
99
157
  tool_name: tool_name ?? undefined,
100
- tool_input: tool_input ? JSON.stringify(tool_input) : undefined,
158
+ tool_input: tool_input ? JSON.stringify(tool_input) : (stopPreview ?? undefined),
101
159
  ts, cwd: resolvedCwd, skill_parent: skillParent, source
102
160
  });
103
- (0, stream_1.broadcast)({ type: 'event', payload: { ...req.body, skill_parent: skillParent } });
161
+ (0, stream_1.broadcast)({ type: 'event', payload: { ...req.body, skill_parent: skillParent, ...(stopPreview ? { text_preview: stopPreview } : {}) } });
104
162
  // Stop limpia el skill activo para esta sesión
105
163
  if (type === 'Stop') {
106
164
  activeSkillBySession.delete(session_id);
@@ -108,6 +166,11 @@ exports.eventsRouter.post('/event', (req, res) => {
108
166
  }
109
167
  // Registrar Agent PreToolUse para detección de sub-sesiones
110
168
  if (type === 'PreToolUse' && tool_name === 'Agent' && resolvedCwd) {
169
+ const cwdCutoff = Date.now() - AGENT_CWD_TTL_MS;
170
+ for (const [cwd, info] of exports.lastAgentByCwd) {
171
+ if (info.pre_ts < cwdCutoff)
172
+ exports.lastAgentByCwd.delete(cwd);
173
+ }
111
174
  exports.lastAgentByCwd.set(resolvedCwd, { pre_ts: ts, session_id });
112
175
  }
113
176
  }
@@ -121,6 +184,17 @@ exports.eventsRouter.post('/event', (req, res) => {
121
184
  const projectCwd = (0, helpers_1.findProjectCwdForFile)(filePath);
122
185
  if (projectCwd)
123
186
  db_1.dbOps.updateSessionProject(session_id, projectCwd);
187
+ // Auto-declare intent for write tools when no active intent exists yet
188
+ if (type === 'PreToolUse' && ['Write', 'Edit'].includes(tool_name || '')) {
189
+ if (!db_1.dbOps.hasActiveIntent(source, filePath)) {
190
+ const conflicts = db_1.dbOps.getWriteConflicts([filePath], source);
191
+ if (conflicts.length === 0) {
192
+ const aid = db_1.dbOps.insertIntent(source, session_id, 'auto: ' + tool_name);
193
+ db_1.dbOps.insertIntentFile(aid, filePath, 'write');
194
+ (0, stream_1.broadcast)({ type: 'intent_declared', payload: { tool: source, files: [filePath], task: 'auto: ' + tool_name } });
195
+ }
196
+ }
197
+ }
124
198
  }
125
199
  }
126
200
  catch { /* ignorar errores de parsing */ }
@@ -197,12 +271,17 @@ exports.eventsRouter.post('/event', (req, res) => {
197
271
  const onCostUpdate = (sessionId, cost, source) => {
198
272
  // Ensure session row exists — sub-agent JSONLs arrive from the enricher without a
199
273
  // prior hook event (Claude Code does not fire hooks for sub-agent sessions).
200
- db_1.dbOps.upsertSession({ id: sessionId, cwd: undefined, started_at: cost.firstTs ?? Date.now(), last_event_at: Date.now(), source });
274
+ db_1.dbOps.upsertSession({ id: sessionId, cwd: undefined, started_at: cost.firstTs ?? Date.now(), last_event_at: cost.lastTs ?? cost.firstTs ?? Date.now(), source });
201
275
  let sessionRow = db_1.dbOps.getSession(sessionId);
202
276
  // Sub-agent detection: first time we see a session, check if its firstTs falls after
203
277
  // a recent Agent PreToolUse from another session in the same CWD → tag as child.
204
278
  if (!exports.taggedSessionParents.has(sessionId) && cost.firstTs) {
205
279
  exports.taggedSessionParents.add(sessionId);
280
+ if (exports.taggedSessionParents.size > TAGGED_PARENTS_MAX) {
281
+ const arr = Array.from(exports.taggedSessionParents);
282
+ for (const id of arr.slice(0, arr.length >> 1))
283
+ exports.taggedSessionParents.delete(id);
284
+ }
206
285
  const cwd = sessionRow?.cwd;
207
286
  if (cwd) {
208
287
  const agentInfo = exports.lastAgentByCwd.get(cwd);
@@ -213,16 +292,44 @@ const onCostUpdate = (sessionId, cost, source) => {
213
292
  }
214
293
  const events = db_1.dbOps.getSessionEvents(sessionId);
215
294
  const report = (0, intelligence_1.analyzeSession)(events, cost.cost_usd);
216
- db_1.dbOps.updateSessionCost(sessionId, cost, report.efficiencyScore, report.loops.length);
295
+ db_1.dbOps.updateSessionCost(sessionId, cost, report.efficiencyScore, report.loops.length, report.exactRetries, report.errorRate, report.fileChurnScore, report.seqCycleCount);
296
+ // ─── Predictive saturation ────────────────────────────────────────────────────
297
+ if (cost.context_used > 0 && cost.context_window > 0) {
298
+ const samples = contextSamples.get(sessionId) ?? [];
299
+ samples.push({ ts: Date.now(), context_used: cost.context_used });
300
+ if (samples.length > CONTEXT_SAMPLES_MAX)
301
+ samples.shift();
302
+ contextSamples.set(sessionId, samples);
303
+ const prediction = (0, intelligence_1.predictSaturation)(samples, cost.context_window);
304
+ if (prediction && prediction.minutesLeft <= SATURATION_WARN_MINUTES) {
305
+ const lastFired = saturationCooldown.get(sessionId) ?? 0;
306
+ if (Date.now() - lastFired >= SATURATION_COOLDOWN_MS) {
307
+ saturationCooldown.set(sessionId, Date.now());
308
+ (0, stream_1.broadcast)({
309
+ type: 'saturation_warning',
310
+ payload: {
311
+ session_id: sessionId,
312
+ minutes_left: prediction.minutesLeft,
313
+ pct_per_min: prediction.pctPerMin,
314
+ context_used: cost.context_used,
315
+ context_window: cost.context_window,
316
+ }
317
+ });
318
+ }
319
+ }
320
+ }
217
321
  const startedAt = sessionRow?.started_at ?? Date.now();
218
322
  const sessionDurationMinutes = (Date.now() - startedAt) / 60000;
219
323
  const projectedHourlyUsd = sessionDurationMinutes > 0.5
220
324
  ? cost.cost_usd / sessionDurationMinutes * 60
221
325
  : 0;
326
+ const sessionSource = sessionRow?.source ?? 'claude-code';
222
327
  (0, stream_1.broadcast)({
223
328
  type: 'cost_update',
224
329
  payload: {
225
330
  session_id: sessionId,
331
+ source: sessionSource,
332
+ started_at: startedAt,
226
333
  cost_usd: cost.cost_usd,
227
334
  input_tokens: cost.input_tokens,
228
335
  output_tokens: cost.output_tokens,
@@ -268,6 +375,30 @@ const onCostUpdate = (sessionId, cost, source) => {
268
375
  outputTokens: cost.lastEntry.outputTokens,
269
376
  }
270
377
  });
378
+ // Semantic extraction: debounced 3s so rapid updates don't cause duplicate parses
379
+ const existing = semanticDebounce.get(sessionId);
380
+ if (existing)
381
+ clearTimeout(existing);
382
+ semanticDebounce.set(sessionId, setTimeout(async () => {
383
+ semanticDebounce.delete(sessionId);
384
+ try {
385
+ const filePath = await (0, claude_code_1.findJSONLForSession)(sessionId);
386
+ if (!filePath)
387
+ return;
388
+ const semantic = await (0, claude_code_1.extractSemanticData)(filePath);
389
+ if (!semantic)
390
+ return;
391
+ db_1.dbOps.upsertAssistantTurns(sessionId, semantic.turns);
392
+ const semLoops = (0, intelligence_1.analyzeSemanticLoops)(semantic.turns);
393
+ db_1.dbOps.updateSessionSemantic(sessionId, semantic.avg_output_chars, semantic.error_block_count, semLoops.length);
394
+ if (semLoops.length > 0) {
395
+ (0, stream_1.broadcast)({ type: 'semantic_loop', payload: { session_id: sessionId, loops: semLoops } });
396
+ }
397
+ }
398
+ catch (err) {
399
+ console.warn('[events] Semantic extraction error:', err);
400
+ }
401
+ }, 3000));
271
402
  }
272
403
  };
273
404
  exports.onCostUpdate = onCostUpdate;
@@ -1,2 +1,7 @@
1
1
  /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
2
2
  export declare function findProjectCwdForFile(filePath: string): string | undefined;
3
+ /**
4
+ * Returns the git root directory for the given directory, or undefined.
5
+ * Uses 'git rev-parse --show-toplevel' — fast and reliable.
6
+ */
7
+ export declare function findGitRoot(fromDir: string): string | undefined;
@@ -4,8 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.findProjectCwdForFile = findProjectCwdForFile;
7
+ exports.findGitRoot = findGitRoot;
7
8
  const path_1 = __importDefault(require("path"));
8
9
  const fs_1 = __importDefault(require("fs"));
10
+ const child_process_1 = require("child_process");
9
11
  /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
10
12
  function findProjectCwdForFile(filePath) {
11
13
  let dir = path_1.default.dirname(filePath);
@@ -17,5 +19,23 @@ function findProjectCwdForFile(filePath) {
17
19
  break;
18
20
  dir = parent;
19
21
  }
20
- return undefined;
22
+ // Fallback: git root
23
+ return findGitRoot(path_1.default.dirname(filePath));
24
+ }
25
+ /**
26
+ * Returns the git root directory for the given directory, or undefined.
27
+ * Uses 'git rev-parse --show-toplevel' — fast and reliable.
28
+ */
29
+ function findGitRoot(fromDir) {
30
+ try {
31
+ const root = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
32
+ cwd: fromDir,
33
+ stdio: 'pipe',
34
+ timeout: 2000,
35
+ }).toString().trim();
36
+ return root || undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
21
41
  }
@@ -22,8 +22,10 @@ exports.historyRouter.get('/history', (_req, res) => {
22
22
  // Detectar modo desde los contadores precalculados en la query
23
23
  const hasAgent = (s.agent_count ?? 0) > 0;
24
24
  const hasSkill = (s.skill_count ?? 0) > 0;
25
- const mode = hasAgent && hasSkill ? 'agentes+skills'
26
- : hasAgent ? 'agentes' : hasSkill ? 'skills' : 'directo';
25
+ const isSubAgent = s.parent_session_id != null;
26
+ const mode = isSubAgent ? 'sub-agente'
27
+ : hasAgent && hasSkill ? 'agentes+skills'
28
+ : hasAgent ? 'agentes' : hasSkill ? 'skills' : 'directo';
27
29
  // Git info cacheada para este proyecto
28
30
  const gitInfo = s.project_path ? (0, projects_cache_1.getCachedGitInfo)(s.project_path) : null;
29
31
  byDate.get(date).push({
@@ -45,7 +47,9 @@ exports.historyRouter.get('/history', (_req, res) => {
45
47
  return [];
46
48
  } })() : [],
47
49
  mode,
50
+ source: s.source ?? 'claude-code',
48
51
  ai_summary: s.ai_summary ?? null,
52
+ is_sub_agent: isSubAgent,
49
53
  git_branch: gitInfo?.branch ?? null,
50
54
  git_dirty: gitInfo?.dirty ?? false,
51
55
  git_ahead: gitInfo?.ahead ?? 0,
@@ -0,0 +1 @@
1
+ export declare const intentsRouter: import("express-serve-static-core").Router;
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ // ─── /api/intent/* — Multi-tool file coordination ─────────────────────────────
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.intentsRouter = void 0;
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const express_1 = require("express");
11
+ const db_1 = require("../db");
12
+ const stream_1 = require("./stream");
13
+ function computeHash(filePath) {
14
+ try {
15
+ if (!fs_1.default.existsSync(filePath))
16
+ return null;
17
+ const content = fs_1.default.readFileSync(filePath);
18
+ return crypto_1.default.createHash('sha256').update(content).digest('hex');
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ // Middleware para validar required `id` en body
25
+ const validateId = (req, res, next) => {
26
+ const { id } = req.body;
27
+ if (!id) {
28
+ res.status(400).json({ error: 'id is required' });
29
+ return;
30
+ }
31
+ next();
32
+ };
33
+ exports.intentsRouter = (0, express_1.Router)();
34
+ // POST /api/intent/declare
35
+ // Body: { tool, session_id, task_desc?, files: [{ file_path, operation?, line_start?, line_end? }] }
36
+ // Returns: { id, status: 'acquired'|'blocked', blocked_by?, files? }
37
+ exports.intentsRouter.post('/api/intent/declare', (req, res) => {
38
+ const { tool, session_id, task_desc, files } = req.body;
39
+ if (!tool || !session_id || !Array.isArray(files) || files.length === 0) {
40
+ res.status(400).json({ error: 'tool, session_id and files[] are required' });
41
+ return;
42
+ }
43
+ const writeFiles = files.filter(f => !f.operation || f.operation === 'write');
44
+ const writePaths = writeFiles
45
+ .map(f => f.file_path)
46
+ .filter((p) => typeof p === 'string' && p.startsWith('/'));
47
+ if (writePaths.length < writeFiles.length) {
48
+ res.status(400).json({ error: 'file_path must be an absolute path (starting with /)' });
49
+ return;
50
+ }
51
+ // Check conflicts from other tools
52
+ const conflicts = writePaths.length > 0 ? db_1.dbOps.getWriteConflicts(writePaths, tool) : [];
53
+ if (conflicts.length > 0) {
54
+ const blockedBy = conflicts[0].tool;
55
+ (0, stream_1.broadcast)({ type: 'intent_conflict', payload: {
56
+ files: conflicts.map(c => c.file_path),
57
+ locked_by: blockedBy,
58
+ session_id: conflicts[0].session_id,
59
+ task: conflicts[0].task_desc,
60
+ } });
61
+ res.json({
62
+ id: null, status: 'blocked',
63
+ blocked_by: blockedBy,
64
+ files: conflicts.map(c => c.file_path),
65
+ });
66
+ return;
67
+ }
68
+ // Acquire
69
+ const id = db_1.dbOps.insertIntent(tool, session_id, task_desc);
70
+ const filePaths = [];
71
+ for (const f of files) {
72
+ if (f.file_path) {
73
+ db_1.dbOps.insertIntentFile(id, f.file_path, f.operation ?? 'write', f.line_start, f.line_end);
74
+ filePaths.push(f.file_path);
75
+ }
76
+ }
77
+ (0, stream_1.broadcast)({ type: 'intent_declared', payload: { tool, files: filePaths, task: task_desc } });
78
+ res.json({ id, status: 'acquired' });
79
+ });
80
+ // POST /api/intent/done
81
+ // Body: { id }
82
+ exports.intentsRouter.post('/api/intent/done', validateId, (req, res) => {
83
+ const { id } = req.body;
84
+ const intent = db_1.dbOps.getIntent(id);
85
+ const files = db_1.dbOps.getIntentFiles(id);
86
+ db_1.dbOps.releaseIntent(id);
87
+ for (const f of files) {
88
+ (0, stream_1.broadcast)({ type: 'intent_released', payload: { file: f.file_path, operation: f.operation, tool: intent?.tool } });
89
+ }
90
+ res.json({ ok: true });
91
+ });
92
+ // POST /api/intent/heartbeat
93
+ // Body: { id }
94
+ exports.intentsRouter.post('/api/intent/heartbeat', validateId, (req, res) => {
95
+ const { id } = req.body;
96
+ db_1.dbOps.heartbeatIntent(id);
97
+ res.json({ ok: true });
98
+ });
99
+ // GET /api/intent/status?files=path1,path2
100
+ exports.intentsRouter.get('/api/intent/status', (req, res) => {
101
+ const raw = req.query.files;
102
+ const tool = req.query.exclude_tool;
103
+ const paths = raw ? raw.split(',').map(p => p.trim()).filter(Boolean) : [];
104
+ if (paths.length === 0) {
105
+ res.json({ conflicts: [] });
106
+ return;
107
+ }
108
+ const conflicts = db_1.dbOps.getWriteConflicts(paths, tool ?? '');
109
+ res.json({
110
+ conflicts: conflicts.map(c => ({
111
+ file: c.file_path,
112
+ locked_by: c.tool,
113
+ task: c.task_desc,
114
+ since: c.acquired_at,
115
+ })),
116
+ });
117
+ });
118
+ // POST /api/intent/check-hash — verify files haven't changed since last known hash
119
+ // Body: { files: [{ file_path: string, hash: string }] }
120
+ // Returns: { safe: boolean, changed: Array<{ file_path, current_hash }> }
121
+ exports.intentsRouter.post('/api/intent/check-hash', (req, res) => {
122
+ const { files } = req.body;
123
+ if (!Array.isArray(files) || files.length === 0) {
124
+ res.status(400).json({ error: 'files[] is required' });
125
+ return;
126
+ }
127
+ const changed = [];
128
+ for (const f of files) {
129
+ const current = computeHash(f.file_path);
130
+ if (current && f.hash && current !== f.hash) {
131
+ changed.push({ file_path: f.file_path, current_hash: current });
132
+ }
133
+ }
134
+ res.json({ safe: changed.length === 0, changed });
135
+ });
136
+ // GET /api/intent/hashes?files=/abs/path1,/abs/path2
137
+ // Returns current SHA256 hashes for the given file paths
138
+ exports.intentsRouter.get('/api/intent/hashes', (req, res) => {
139
+ const raw = req.query.files;
140
+ const paths = raw ? raw.split(',').map(p => p.trim()).filter((p) => !!p && p.startsWith('/')) : [];
141
+ if (paths.length === 0) {
142
+ res.status(400).json({ error: 'files query param required (comma-separated absolute paths)' });
143
+ return;
144
+ }
145
+ const hashes = {};
146
+ for (const p of paths) {
147
+ hashes[p] = computeHash(p);
148
+ }
149
+ res.json({ hashes });
150
+ });
151
+ // GET /api/intent/active — list all currently active intents
152
+ exports.intentsRouter.get('/api/intent/active', (_req, res) => {
153
+ const intents = db_1.dbOps.getActiveIntents();
154
+ res.json({ intents });
155
+ });
@@ -22,6 +22,7 @@ const projects_1 = require("./projects");
22
22
  const session_state_1 = require("../session-state");
23
23
  const stream_1 = require("./stream");
24
24
  const paths_1 = require("../paths");
25
+ const opencode_1 = require("../watchers/opencode");
25
26
  const cost_projector_1 = require("../cost-projector");
26
27
  exports.miscRouter = (0, express_1.Router)();
27
28
  // ─── GET /git?path=... — git info para un proyecto ────────────────────────────
@@ -61,7 +62,8 @@ exports.miscRouter.get('/intelligence/:sessionId', (req, res) => {
61
62
  return;
62
63
  }
63
64
  const events = db_1.dbOps.getSessionEvents(sessionId);
64
- const report = (0, intelligence_1.analyzeSession)(events, session.total_cost_usd ?? 0);
65
+ const cfg = (0, config_1.readConfig)();
66
+ const report = (0, intelligence_1.analyzeSession)(events, session.total_cost_usd ?? 0, cfg.loopThreshold, cfg.loopWindowSecs * 1000);
65
67
  res.json({ sessionId, ...report });
66
68
  });
67
69
  // ─── GET /quota — datos de cuota y burn rate ──────────────────────────────────
@@ -134,7 +136,7 @@ exports.miscRouter.get('/claude-stats', (_req, res) => {
134
136
  });
135
137
  // ─── GET /api/active-sessions — fuentes activas en los últimos 5 min ──────────
136
138
  exports.miscRouter.get('/api/active-sessions', (_req, res) => {
137
- const cutoff = Date.now() - 5 * 60 * 1000;
139
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
138
140
  const sessions = db_1.dbOps.getAllSessions();
139
141
  const bySource = new Map();
140
142
  for (const s of sessions) {
@@ -153,10 +155,16 @@ exports.miscRouter.get('/api/active-sessions', (_req, res) => {
153
155
  output_tokens: s.total_output_tokens ?? 0,
154
156
  cache_read: s.total_cache_read ?? 0,
155
157
  cache_creation: s.total_cache_creation ?? 0,
158
+ project: s.project_path ?? s.cwd ?? null,
156
159
  });
157
160
  }
158
161
  }
159
- const result = Array.from(bySource.entries()).map(([source, v]) => ({ source, ...v }));
162
+ const KNOWN_SOURCES = new Set(['claude-code', 'opencode', 'codex', 'amp', 'droid', 'codebuff']);
163
+ let result = Array.from(bySource.entries())
164
+ .filter(([source]) => KNOWN_SOURCES.has(source))
165
+ .map(([source, v]) => ({ source, ...v }));
166
+ // OpenCode: filter out archived sessions (user closed the tool)
167
+ result = result.filter(s => s.source !== 'opencode' || !(0, opencode_1.isSessionArchived)(s.sessionId));
160
168
  res.json(result);
161
169
  });
162
170
  // ─── GET /system-config — mapa completo del setup de Claude ──────────────────
@@ -262,7 +270,74 @@ exports.miscRouter.get('/system-config', (_req, res) => {
262
270
  const modeDistribution = db_1.dbOps.getModeDistribution(7);
263
271
  // 6. Config de claudestat
264
272
  const claudestatConfig = (0, config_1.readConfig)();
265
- _systemConfigCache = { hooks, agents, workflows, skills, contextFiles, memoryFiles, modeDistribution, claudestatConfig };
273
+ // ─── 7. OpenCode data ────────────────────────────────────────────────────────
274
+ const opencodeDir = (0, paths_1.getOpencodeDir)();
275
+ let opencodeConfig = null;
276
+ try {
277
+ opencodeConfig = JSON.parse(fs_1.default.readFileSync(path_1.default.join(opencodeDir, 'opencode.json'), 'utf-8'));
278
+ }
279
+ catch { }
280
+ let opencodeAgentsMd = null;
281
+ try {
282
+ const p = path_1.default.join(opencodeDir, 'AGENTS.md');
283
+ const content = fs_1.default.readFileSync(p, 'utf-8');
284
+ opencodeAgentsMd = { lines: content.split('\n').length, sizeKb: Math.round(Buffer.byteLength(content, 'utf-8') / 1024 * 10) / 10 };
285
+ }
286
+ catch { }
287
+ let opencodeSkills = [];
288
+ try {
289
+ const skillsDir = path_1.default.join(opencodeDir, 'skills');
290
+ for (const entry of fs_1.default.readdirSync(skillsDir, { withFileTypes: true })) {
291
+ if (entry.isDirectory()) {
292
+ const skillMd = path_1.default.join(skillsDir, entry.name, 'SKILL.md');
293
+ try {
294
+ const content = fs_1.default.readFileSync(skillMd, 'utf-8');
295
+ const lines = content.split('\n').length;
296
+ const description = content.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
297
+ opencodeSkills.push({ name: entry.name, description, lines });
298
+ }
299
+ catch { }
300
+ }
301
+ }
302
+ }
303
+ catch { }
304
+ let opencodeAgents = [];
305
+ try {
306
+ const agentsDir = path_1.default.join(opencodeDir, 'agents');
307
+ opencodeAgents = fs_1.default.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
308
+ }
309
+ catch { }
310
+ let opencodeProjects = 0;
311
+ try {
312
+ const raw = JSON.parse(fs_1.default.readFileSync(path_1.default.join(opencodeDir, 'projects.json'), 'utf-8'));
313
+ opencodeProjects = Object.keys(raw.projects ?? {}).length;
314
+ }
315
+ catch { }
316
+ let opencodeCommands = [];
317
+ try {
318
+ const cmdsDir = path_1.default.join(opencodeDir, 'commands');
319
+ opencodeCommands = fs_1.default.readdirSync(cmdsDir).filter(f => f.endsWith('.md'));
320
+ }
321
+ catch { }
322
+ const opencodePlugins = [];
323
+ try {
324
+ const pluginsDir = path_1.default.join(opencodeDir, 'plugins');
325
+ opencodePlugins.push(...fs_1.default.readdirSync(pluginsDir).filter(f => f.endsWith('.ts') || f.endsWith('.js')));
326
+ }
327
+ catch { }
328
+ _systemConfigCache = {
329
+ hooks, agents, workflows, skills, contextFiles, memoryFiles,
330
+ modeDistribution, claudestatConfig,
331
+ opencode: {
332
+ config: opencodeConfig,
333
+ agentsMd: opencodeAgentsMd,
334
+ skills: opencodeSkills,
335
+ agents: opencodeAgents,
336
+ projects: opencodeProjects,
337
+ commands: opencodeCommands,
338
+ plugins: opencodePlugins,
339
+ },
340
+ };
266
341
  _systemConfigCacheTs = Date.now();
267
342
  res.json(_systemConfigCache);
268
343
  }
@@ -294,3 +369,74 @@ exports.miscRouter.put('/config', (req, res) => {
294
369
  exports.miscRouter.get('/cost-projection', (_req, res) => {
295
370
  res.json((0, cost_projector_1.computeProjection)(90));
296
371
  });
372
+ // ─── GET /coordination/status — detección automática de herramienta activa ───
373
+ exports.miscRouter.get('/coordination/status', (req, res) => {
374
+ const project = req.query.project;
375
+ const tool = req.query.tool ?? 'unknown';
376
+ if (!project) {
377
+ res.status(400).json({ error: 'project is required' });
378
+ return;
379
+ }
380
+ const active = db_1.dbOps.getActiveToolsInProject(project, tool);
381
+ res.json({
382
+ other_tool_active: active.length > 0,
383
+ tools: active.map(r => r.source),
384
+ since: active.length > 0 ? Math.min(...active.map(r => r.last_event_at)) : null,
385
+ });
386
+ });
387
+ // ─── GET /tool-status — estado de cada tool (claude-code, opencode) ──────────
388
+ const AI_COLLAB_STATUS = path_1.default.join(process.env.HOME ?? '/tmp', '.ai-collab', 'STATUS.json');
389
+ exports.miscRouter.get('/tool-status', (_req, res) => {
390
+ try {
391
+ const raw = fs_1.default.readFileSync(AI_COLLAB_STATUS, 'utf-8');
392
+ res.json(JSON.parse(raw));
393
+ }
394
+ catch {
395
+ res.json({
396
+ 'claude-code': { status: 'unknown', last_task: null, finished_at: null, session_id: null, waiting_for: null },
397
+ 'opencode': { status: 'unknown', last_task: null, finished_at: null, session_id: null, waiting_for: null },
398
+ });
399
+ }
400
+ });
401
+ // ─── POST /tool-status — actualizar estado de un tool ────────────────────────
402
+ exports.miscRouter.post('/tool-status', (req, res) => {
403
+ const { tool, status, last_task, session_id, waiting_for } = req.body;
404
+ if (!tool || !status) {
405
+ res.status(400).json({ error: 'tool and status are required' });
406
+ return;
407
+ }
408
+ let current = {};
409
+ try {
410
+ current = JSON.parse(fs_1.default.readFileSync(AI_COLLAB_STATUS, 'utf-8'));
411
+ }
412
+ catch { }
413
+ const finished_at = status === 'idle' ? Date.now() : null;
414
+ current[tool] = { status, last_task: last_task ?? null, finished_at, session_id: session_id ?? null, waiting_for: waiting_for ?? null };
415
+ try {
416
+ fs_1.default.mkdirSync(path_1.default.dirname(AI_COLLAB_STATUS), { recursive: true });
417
+ fs_1.default.writeFileSync(AI_COLLAB_STATUS, JSON.stringify(current, null, 2), 'utf-8');
418
+ }
419
+ catch (e) {
420
+ res.status(500).json({ error: String(e) });
421
+ return;
422
+ }
423
+ (0, stream_1.broadcast)({ type: 'tool_status_changed', payload: { tool, status, last_task: last_task ?? null, finished_at, session_id: session_id ?? null, waiting_for: waiting_for ?? null } });
424
+ res.json({ ok: true });
425
+ });
426
+ // ─── GET /api/logs — últimos N líneas del daemon log ──────────────────────────
427
+ exports.miscRouter.get('/api/logs', (req, res) => {
428
+ const n = Math.min(parseInt(req.query.n, 10) || 50, 500);
429
+ const logFile = (0, paths_1.getDaemonLogFile)();
430
+ try {
431
+ if (!fs_1.default.existsSync(logFile)) {
432
+ res.json({ lines: [] });
433
+ return;
434
+ }
435
+ const content = fs_1.default.readFileSync(logFile, 'utf8');
436
+ const lines = content.split('\n').filter(Boolean);
437
+ res.json({ lines: lines.slice(-n) });
438
+ }
439
+ catch {
440
+ res.json({ lines: [] });
441
+ }
442
+ });