@claudecam/server 0.1.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 (52) hide show
  1. package/dist/db/index.js +68 -0
  2. package/dist/db/index.js.map +1 -0
  3. package/dist/db/queries.js +658 -0
  4. package/dist/db/queries.js.map +1 -0
  5. package/dist/db/schema.sql +259 -0
  6. package/dist/index.js +128 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/routes/agents.js +68 -0
  9. package/dist/routes/agents.js.map +1 -0
  10. package/dist/routes/correlation-audit.js +31 -0
  11. package/dist/routes/correlation-audit.js.map +1 -0
  12. package/dist/routes/events.js +81 -0
  13. package/dist/routes/events.js.map +1 -0
  14. package/dist/routes/files.js +24 -0
  15. package/dist/routes/files.js.map +1 -0
  16. package/dist/routes/parse-prd.js +38 -0
  17. package/dist/routes/parse-prd.js.map +1 -0
  18. package/dist/routes/projects.js +96 -0
  19. package/dist/routes/projects.js.map +1 -0
  20. package/dist/routes/registry.js +88 -0
  21. package/dist/routes/registry.js.map +1 -0
  22. package/dist/routes/session-groups.js +182 -0
  23. package/dist/routes/session-groups.js.map +1 -0
  24. package/dist/routes/sessions.js +109 -0
  25. package/dist/routes/sessions.js.map +1 -0
  26. package/dist/routes/sprints.js +58 -0
  27. package/dist/routes/sprints.js.map +1 -0
  28. package/dist/routes/stats.js +63 -0
  29. package/dist/routes/stats.js.map +1 -0
  30. package/dist/routes/stream.js +21 -0
  31. package/dist/routes/stream.js.map +1 -0
  32. package/dist/routes/tasks.js +198 -0
  33. package/dist/routes/tasks.js.map +1 -0
  34. package/dist/services/correlation-engine.js +577 -0
  35. package/dist/services/correlation-engine.js.map +1 -0
  36. package/dist/services/event-processor.js +857 -0
  37. package/dist/services/event-processor.js.map +1 -0
  38. package/dist/services/prd-parser.js +142 -0
  39. package/dist/services/prd-parser.js.map +1 -0
  40. package/dist/services/project-manager.js +351 -0
  41. package/dist/services/project-manager.js.map +1 -0
  42. package/dist/services/project-router.js +56 -0
  43. package/dist/services/project-router.js.map +1 -0
  44. package/dist/services/session-manager.js +76 -0
  45. package/dist/services/session-manager.js.map +1 -0
  46. package/dist/services/sse-manager.js +115 -0
  47. package/dist/services/sse-manager.js.map +1 -0
  48. package/dist/services/string-similarity.js +256 -0
  49. package/dist/services/string-similarity.js.map +1 -0
  50. package/dist/services/task-completion.js +251 -0
  51. package/dist/services/task-completion.js.map +1 -0
  52. package/package.json +59 -0
@@ -0,0 +1,857 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { FILE_CHANGE_TOOLS, FILE_READ_TOOLS, COMMAND_TOOLS, MESSAGE_TOOLS, MAX_INPUT_LENGTH, MAX_OUTPUT_LENGTH, } from "@claudecam/shared";
3
+ import { eventQueries, agentQueries, sessionQueries, fileChangeQueries, taskItemQueries, } from "../db/queries.js";
4
+ import { sseManager } from "./sse-manager.js";
5
+ import { bindSessionToProject, getProjectForSession, getSessionsForProject, } from "./project-router.js";
6
+ import { autoCompleteTasksForSession, autoCompleteTasksForAgent, markPrdTaskCompleted, } from "./task-completion.js";
7
+ import { prdTaskQueries, agentTaskBindingQueries, } from "../db/queries.js";
8
+ /**
9
+ * Track spawned subagents per session for SubagentStop correlation.
10
+ * When a Task tool is detected, we create a virtual agent and queue its ID.
11
+ * When SubagentStop fires, we dequeue the oldest virtual agent (FIFO).
12
+ */
13
+ const spawnedSubagentQueue = new Map();
14
+ /**
15
+ * Queue of pending agent names from Task tool calls.
16
+ * When main agent spawns a subagent via Task tool with a `name` parameter,
17
+ * we queue that name. When a new SessionStart arrives (the subagent starting),
18
+ * we dequeue and assign the name to that session's agent.
19
+ */
20
+ const pendingAgentNames = [];
21
+ /** Track the first session ID seen - this is the main/leader agent. */
22
+ let firstMainSessionId = null;
23
+ /**
24
+ * Track agent name usage counts per session for deduplication.
25
+ * Key: "sessionId::name" → count of agents with that name in that session.
26
+ */
27
+ const agentNameCounts = new Map();
28
+ // NOTE: Stale session timeout REMOVED.
29
+ // Sessions should only be completed by explicit SessionEnd hooks,
30
+ // not by inactivity timeouts. A user reading Claude's output or thinking
31
+ // about the next prompt does NOT mean the session ended.
32
+ /** Window (ms) for retroactive agent name updates. Agents created within this window can be renamed. */
33
+ const RETROACTIVE_NAME_WINDOW_MS = 60 * 1000;
34
+ // ---------------------------------------------------------------------------
35
+ // Session Name Extraction (Feature A: smart heuristic)
36
+ // ---------------------------------------------------------------------------
37
+ /** Words/patterns to skip at the start of a prompt (greetings, filler, terminal prompts). */
38
+ const SKIP_PATTERNS = [
39
+ // Terminal prompts (user@host, PS1, etc.)
40
+ /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+\s/,
41
+ /^[A-Z]:\\.*[>$#]\s*/,
42
+ /^\/[a-z].*[>$#]\s*/,
43
+ /^MINGW\d*_NT\S*\s*/,
44
+ /^\$\s*/,
45
+ /^>\s*/,
46
+ // Common greetings and filler words (PT-BR + EN)
47
+ /^(oi|olá|ola|hey|hi|hello|e aí|eai|fala|bom dia|boa tarde|boa noite|ta|tá|ok|okay|certo|beleza|blz|sim|yes|then|so|entao|então|agora|now|please|por favor|pfv|pf)[,.\s!]+/i,
48
+ ];
49
+ /** Extract a meaningful session name from the first user prompt. */
50
+ function extractSessionName(rawInput) {
51
+ let text = typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
52
+ // Unwrap JSON wrapper: {"prompt": "actual text"}
53
+ try {
54
+ const parsed = JSON.parse(text);
55
+ if (parsed && typeof parsed.prompt === 'string')
56
+ text = parsed.prompt;
57
+ else if (parsed && typeof parsed.message === 'string')
58
+ text = parsed.message;
59
+ else if (parsed && parsed.message?.content)
60
+ text = parsed.message.content;
61
+ }
62
+ catch { /* not JSON, use raw */ }
63
+ // Strip system/XML tags with content
64
+ text = text
65
+ .replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '')
66
+ .replace(/<[^>]+>/g, '');
67
+ // Normalize whitespace
68
+ text = text
69
+ .replace(/[\r\n]+/g, ' ')
70
+ .replace(/\s+/g, ' ')
71
+ .trim();
72
+ // Strip leading markdown formatting
73
+ text = text.replace(/^[\s#*\->`]+/, '').trim();
74
+ // Apply skip patterns repeatedly until no more matches
75
+ let prev = '';
76
+ while (prev !== text) {
77
+ prev = text;
78
+ for (const pattern of SKIP_PATTERNS) {
79
+ text = text.replace(pattern, '').trim();
80
+ }
81
+ }
82
+ if (text.length < 3)
83
+ return undefined;
84
+ // Extract first sentence (up to . ? ! or comma if long enough)
85
+ const sentenceMatch = text.match(/^(.+?)[.?!](?:\s|$)/);
86
+ const firstSentence = sentenceMatch ? sentenceMatch[1].trim() : text;
87
+ // Truncate to ~50 chars at word boundary
88
+ const MAX_NAME_LENGTH = 50;
89
+ if (firstSentence.length <= MAX_NAME_LENGTH) {
90
+ return capitalize(firstSentence);
91
+ }
92
+ const words = firstSentence.split(' ');
93
+ let name = '';
94
+ for (const word of words) {
95
+ const candidate = name ? `${name} ${word}` : word;
96
+ if (candidate.length > MAX_NAME_LENGTH)
97
+ break;
98
+ name = candidate;
99
+ }
100
+ return name ? capitalize(name) + '...' : undefined;
101
+ }
102
+ /** Capitalize first letter only. */
103
+ function capitalize(s) {
104
+ if (!s)
105
+ return s;
106
+ return s.charAt(0).toUpperCase() + s.slice(1);
107
+ }
108
+ function truncate(val, maxLen) {
109
+ if (val === undefined || val === null)
110
+ return undefined;
111
+ const str = typeof val === "string" ? val : JSON.stringify(val);
112
+ if (str.length <= maxLen)
113
+ return str;
114
+ return str.slice(0, maxLen) + "...";
115
+ }
116
+ function categorizeEvent(hookType, toolName) {
117
+ if (hookType === "Notification")
118
+ return "notification";
119
+ if (hookType === "PreCompact" || hookType === "PostCompact")
120
+ return "compact";
121
+ if (hookType === "Stop" ||
122
+ hookType === "SubagentStop" ||
123
+ hookType === "SubagentStart" ||
124
+ hookType === "SessionStart" ||
125
+ hookType === "SessionEnd" ||
126
+ hookType === "UserPromptSubmit")
127
+ return "lifecycle";
128
+ if (hookType === "ToolError" ||
129
+ hookType === "PreToolUseRejected" ||
130
+ hookType === "PostToolUseFailure")
131
+ return "error";
132
+ if (toolName) {
133
+ if (FILE_CHANGE_TOOLS.includes(toolName))
134
+ return "file_change";
135
+ if (COMMAND_TOOLS.includes(toolName))
136
+ return "command";
137
+ if (MESSAGE_TOOLS.includes(toolName))
138
+ return "message";
139
+ }
140
+ return "tool_call";
141
+ }
142
+ function extractFilePath(toolName, data, input) {
143
+ if (!data && !input)
144
+ return undefined;
145
+ const sources = [
146
+ data,
147
+ typeof input === "object" && input !== null
148
+ ? input
149
+ : undefined,
150
+ ];
151
+ for (const src of sources) {
152
+ if (!src)
153
+ continue;
154
+ // Check top-level file_path first (hook sends it directly in data)
155
+ const directPath = src["file_path"] ?? src["path"] ?? src["filePath"];
156
+ if (typeof directPath === "string")
157
+ return directPath;
158
+ // Check inside tool_input
159
+ let toolInput = src["tool_input"];
160
+ // If tool_input is a string (serialized JSON), parse it
161
+ if (typeof toolInput === "string") {
162
+ try {
163
+ toolInput = JSON.parse(toolInput);
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ }
169
+ if (typeof toolInput === "object" && toolInput !== null) {
170
+ const ti = toolInput;
171
+ const path = ti["file_path"] ?? ti["path"] ?? ti["filePath"];
172
+ if (typeof path === "string")
173
+ return path;
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+ function extractToolName(incoming) {
179
+ if (incoming.tool)
180
+ return incoming.tool;
181
+ if (incoming.data?.["tool_name"] &&
182
+ typeof incoming.data["tool_name"] === "string") {
183
+ return incoming.data["tool_name"];
184
+ }
185
+ return undefined;
186
+ }
187
+ function extractDuration(data) {
188
+ if (!data)
189
+ return undefined;
190
+ const dur = data["duration_ms"] ?? data["duration"];
191
+ return typeof dur === "number" ? dur : undefined;
192
+ }
193
+ function extractError(data) {
194
+ if (!data)
195
+ return undefined;
196
+ const err = data["error_message"] ?? data["error"];
197
+ return typeof err === "string" ? err : undefined;
198
+ }
199
+ /**
200
+ * Broadcast an event to all sessions belonging to the same project,
201
+ * EXCLUDING the originating session (to avoid duplicate delivery).
202
+ */
203
+ function broadcastToProjectExcluding(projectId, eventType, data, excludeSessionId) {
204
+ try {
205
+ const sessionIds = getSessionsForProject(projectId);
206
+ for (const sid of sessionIds) {
207
+ if (sid !== excludeSessionId) {
208
+ sseManager.broadcast(eventType, data, sid);
209
+ }
210
+ }
211
+ }
212
+ catch {
213
+ // Ignore broadcast errors (DB not ready, etc.)
214
+ }
215
+ }
216
+ export function processEvent(incoming) {
217
+ const now = new Date().toISOString();
218
+ const sessionId = incoming.session_id || "default";
219
+ const agentId = incoming.agent_id || "main";
220
+ const toolName = extractToolName(incoming);
221
+ const category = categorizeEvent(incoming.hook, toolName);
222
+ const inputStr = incoming.data?.["tool_input"] ?? incoming.input ?? incoming.data;
223
+ const outputStr = incoming.data?.["tool_output"] ?? incoming.data?.["output"];
224
+ const event = {
225
+ id: randomUUID(),
226
+ sessionId,
227
+ agentId,
228
+ timestamp: incoming.timestamp || now,
229
+ hookType: incoming.hook,
230
+ category,
231
+ tool: toolName,
232
+ filePath: extractFilePath(toolName, incoming.data, incoming.input),
233
+ input: truncate(inputStr, MAX_INPUT_LENGTH),
234
+ output: truncate(outputStr, MAX_OUTPUT_LENGTH),
235
+ error: extractError(incoming.data),
236
+ duration: extractDuration(incoming.data),
237
+ metadata: incoming.data,
238
+ };
239
+ persistEvent(event, now);
240
+ // Broadcast event to the session's own listeners
241
+ sseManager.broadcast("agent_event", event, sessionId);
242
+ // Cross-broadcast to all sessions in the same project
243
+ const projectId = getProjectForSession(sessionId);
244
+ if (projectId) {
245
+ broadcastToProjectExcluding(projectId, "agent_event", event, sessionId);
246
+ }
247
+ return event;
248
+ }
249
+ function persistEvent(event, now) {
250
+ // Ensure session exists
251
+ const existingSession = sessionQueries.getById().get(event.sessionId);
252
+ if (!existingSession) {
253
+ const workDir = event.metadata?.["working_directory"] || process.cwd();
254
+ sessionQueries
255
+ .insert()
256
+ .run(event.sessionId, event.timestamp, workDir, "active", 0, 0, null);
257
+ }
258
+ else if (existingSession["status"] === "completed" ||
259
+ existingSession["status"] === "error") {
260
+ // Reactivate session when new events arrive (e.g., after context compaction
261
+ // or user reopens Claude after SessionEnd)
262
+ sessionQueries.updateStatus().run("active", null, event.sessionId);
263
+ // Re-activate the actual agent that sent the event (not hardcoded "main")
264
+ agentQueries.updateStatus().run("active", now, event.agentId, event.sessionId);
265
+ const reactivatePayload = {
266
+ session: event.sessionId,
267
+ status: "active",
268
+ };
269
+ sseManager.broadcast("session_status", reactivatePayload, event.sessionId);
270
+ // Cross-broadcast session reactivation to project peers
271
+ const pidReactivate = getProjectForSession(event.sessionId);
272
+ if (pidReactivate) {
273
+ broadcastToProjectExcluding(pidReactivate, "session_status", reactivatePayload, event.sessionId);
274
+ }
275
+ }
276
+ // Bind session to project via working_directory (Project-First Architecture)
277
+ const isNewSession = !existingSession;
278
+ if (isNewSession || event.hookType === "SessionStart") {
279
+ const workDir = event.metadata?.["working_directory"] || "";
280
+ if (workDir) {
281
+ bindSessionToProject(event.sessionId, workDir);
282
+ }
283
+ }
284
+ sessionQueries.incrementEventCount().run(event.sessionId);
285
+ // Name session from first user prompt (UserPromptSubmit)
286
+ if (event.hookType === 'UserPromptSubmit' && event.input) {
287
+ const currentSession = sessionQueries.getById().get(event.sessionId);
288
+ const currentMeta = currentSession?.['metadata'] ? JSON.parse(currentSession['metadata']) : {};
289
+ // Only auto-name if no name set, or if name was auto-generated (not user-edited)
290
+ if (!currentMeta.name) {
291
+ const autoName = extractSessionName(event.input);
292
+ if (autoName) {
293
+ currentMeta.name = autoName;
294
+ currentMeta.nameSource = 'auto';
295
+ sessionQueries.updateMetadata().run(JSON.stringify(currentMeta), event.sessionId);
296
+ }
297
+ }
298
+ }
299
+ // Ensure agent exists
300
+ const existingAgent = agentQueries
301
+ .getById()
302
+ .get(event.agentId, event.sessionId);
303
+ if (!existingAgent) {
304
+ // Agent name resolution (priority order):
305
+ // 1. Pending name from Task tool queue (subagent name from parent)
306
+ // 2. agent_name from event metadata
307
+ // 3. Model name from SessionStart (e.g., "Opus 4.6") - for first session only
308
+ // 4. "main" if first session and no model available
309
+ // 5. Meaningful fallback (agent type or sequential "agent-N", never UUID)
310
+ const agentType = event.metadata?.["agent_type"] || "general-purpose";
311
+ // Track the very first session - this is the main/leader agent
312
+ if (!firstMainSessionId) {
313
+ firstMainSessionId = event.sessionId;
314
+ }
315
+ const isMainSession = event.sessionId === firstMainSessionId;
316
+ // Check pending names from Task tool queue - available for ALL agents
317
+ const pendingName = pendingAgentNames.length > 0
318
+ ? pendingAgentNames.shift()
319
+ : undefined;
320
+ // Extract model name from SessionStart metadata (e.g., "claude-opus-4-6" → "Opus 4.6")
321
+ // Available for ALL agents, not just main (subagents also report their model).
322
+ const modelName = formatModelName(event.metadata?.["model"] || "");
323
+ // Name resolution (priority order):
324
+ // 1. Pending name from Task tool queue (e.g., "researcher")
325
+ // 2. metadata.agent_name (if hook sends it - currently never)
326
+ // 3. Main/leader agent: model name or "main"
327
+ // 4. Smart fallback: descriptive agent_type > model name > "Subagent" (deduplicated)
328
+ const agentName = pendingName // 1: Task tool name ("researcher")
329
+ || event.metadata?.["agent_name"] // 2: metadata.agent_name
330
+ || (isMainSession ? (modelName || "main") : null) // 3: model name or "main" for leader
331
+ || generateAgentName(agentType, modelName, event.sessionId); // 4: type/model/Subagent (never UUID)
332
+ agentQueries
333
+ .upsert()
334
+ .run(event.agentId, event.sessionId, agentName, agentType, "active", event.timestamp, event.timestamp);
335
+ const agents = agentQueries
336
+ .getBySession()
337
+ .all(event.sessionId);
338
+ sessionQueries.updateAgentCount().run(agents.length, event.sessionId);
339
+ // Emit agent_created SSE event
340
+ const agentCreatedPayload = {
341
+ agent: event.agentId,
342
+ sessionId: event.sessionId,
343
+ name: agentName,
344
+ type: agentType,
345
+ status: "active",
346
+ timestamp: event.timestamp,
347
+ };
348
+ sseManager.broadcast("agent_created", agentCreatedPayload, event.sessionId);
349
+ // Cross-broadcast agent_created to project peers
350
+ const pidCreated = getProjectForSession(event.sessionId);
351
+ if (pidCreated) {
352
+ broadcastToProjectExcluding(pidCreated, "agent_created", agentCreatedPayload, event.sessionId);
353
+ }
354
+ }
355
+ // Update agent status
356
+ if (event.hookType === "PreToolUse" || event.hookType === "PostToolUse") {
357
+ // Only count tool calls and broadcast status on PostToolUse
358
+ // PreToolUse only updates lastActivityAt silently (no SSE broadcast)
359
+ if (event.hookType === "PostToolUse") {
360
+ agentQueries
361
+ .incrementToolCalls()
362
+ .run(now, event.agentId, event.sessionId);
363
+ agentQueries
364
+ .updateStatus()
365
+ .run("active", now, event.agentId, event.sessionId);
366
+ const activePayload = {
367
+ agent: event.agentId,
368
+ sessionId: event.sessionId,
369
+ status: "active",
370
+ };
371
+ sseManager.broadcast("agent_status", activePayload, event.sessionId);
372
+ // Cross-broadcast active status to project peers
373
+ const pidActive = getProjectForSession(event.sessionId);
374
+ if (pidActive) {
375
+ broadcastToProjectExcluding(pidActive, "agent_status", activePayload, event.sessionId);
376
+ }
377
+ }
378
+ else {
379
+ // PreToolUse: just update timestamp, no SSE broadcast
380
+ agentQueries
381
+ .updateStatus()
382
+ .run("active", now, event.agentId, event.sessionId);
383
+ }
384
+ }
385
+ if (event.category === "error") {
386
+ agentQueries.incrementErrors().run(now, event.agentId, event.sessionId);
387
+ agentQueries
388
+ .updateStatus()
389
+ .run("error", now, event.agentId, event.sessionId);
390
+ const errorPayload = {
391
+ agent: event.agentId,
392
+ sessionId: event.sessionId,
393
+ status: "error",
394
+ };
395
+ sseManager.broadcast("agent_status", errorPayload, event.sessionId);
396
+ // Cross-broadcast error status to project peers
397
+ const pidError = getProjectForSession(event.sessionId);
398
+ if (pidError) {
399
+ broadcastToProjectExcluding(pidError, "agent_status", errorPayload, event.sessionId);
400
+ }
401
+ }
402
+ if (event.hookType === "Stop") {
403
+ // Stop = Claude finished responding, waiting for next prompt.
404
+ // This does NOT mean the session ended - mark agent as "idle", not "completed".
405
+ // Only SessionEnd should mark things as truly completed.
406
+ agentQueries
407
+ .updateStatus()
408
+ .run("idle", now, event.agentId, event.sessionId);
409
+ const idlePayload = {
410
+ agent: event.agentId,
411
+ sessionId: event.sessionId,
412
+ status: "idle",
413
+ };
414
+ sseManager.broadcast("agent_status", idlePayload, event.sessionId);
415
+ // Cross-broadcast idle status to project peers
416
+ const pidStop = getProjectForSession(event.sessionId);
417
+ if (pidStop) {
418
+ broadcastToProjectExcluding(pidStop, "agent_status", idlePayload, event.sessionId);
419
+ }
420
+ }
421
+ // SessionEnd = session truly ended (user closed Claude or session expired).
422
+ // THIS is where we mark the session and all its agents as completed.
423
+ if (event.hookType === "SessionEnd") {
424
+ // Mark all active/idle agents in this session as completed
425
+ const agents = agentQueries.getBySession().all(event.sessionId);
426
+ for (const agent of agents) {
427
+ const agentStatus = agent["status"];
428
+ if (agentStatus === "active" || agentStatus === "idle") {
429
+ const agentId = agent["id"];
430
+ agentQueries
431
+ .updateStatus()
432
+ .run("completed", now, agentId, event.sessionId);
433
+ const agentCompletedPayload = {
434
+ agent: agentId,
435
+ sessionId: event.sessionId,
436
+ status: "completed",
437
+ };
438
+ sseManager.broadcast("agent_status", agentCompletedPayload, event.sessionId);
439
+ }
440
+ }
441
+ // Mark session as completed
442
+ sessionQueries.updateStatus().run("completed", now, event.sessionId);
443
+ const sessionCompletedPayload = {
444
+ session: event.sessionId,
445
+ status: "completed",
446
+ };
447
+ sseManager.broadcast("session_status", sessionCompletedPayload, event.sessionId);
448
+ // Cross-broadcast session completion to project peers
449
+ const pidEnd = getProjectForSession(event.sessionId);
450
+ if (pidEnd) {
451
+ broadcastToProjectExcluding(pidEnd, "session_status", sessionCompletedPayload, event.sessionId);
452
+ for (const agent of agents) {
453
+ const agentStatus = agent["status"];
454
+ if (agentStatus === "active" || agentStatus === "idle") {
455
+ broadcastToProjectExcluding(pidEnd, "agent_status", {
456
+ agent: agent["id"],
457
+ sessionId: event.sessionId,
458
+ status: "completed",
459
+ }, event.sessionId);
460
+ }
461
+ }
462
+ }
463
+ // Auto-complete tasks bound to agents in this session
464
+ try {
465
+ autoCompleteTasksForSession(event.sessionId);
466
+ }
467
+ catch {
468
+ // Task completion errors should not break event processing
469
+ }
470
+ }
471
+ if (event.hookType === "SubagentStop") {
472
+ // Correlate with spawned virtual agents (FIFO queue)
473
+ const queue = spawnedSubagentQueue.get(event.sessionId);
474
+ if (queue && queue.length > 0) {
475
+ const subagentId = queue.shift();
476
+ agentQueries
477
+ .updateStatus()
478
+ .run("shutdown", now, subagentId, event.sessionId);
479
+ const shutdownPayload = {
480
+ agent: subagentId,
481
+ sessionId: event.sessionId,
482
+ status: "shutdown",
483
+ };
484
+ sseManager.broadcast("agent_status", shutdownPayload, event.sessionId);
485
+ // Auto-complete tasks bound to this subagent with high confidence
486
+ try {
487
+ autoCompleteTasksForAgent(subagentId, event.sessionId);
488
+ }
489
+ catch {
490
+ // Task completion errors should not break event processing
491
+ }
492
+ // Cross-broadcast shutdown status to project peers
493
+ const pidShutdown = getProjectForSession(event.sessionId);
494
+ if (pidShutdown) {
495
+ broadcastToProjectExcluding(pidShutdown, "agent_status", shutdownPayload, event.sessionId);
496
+ }
497
+ }
498
+ // NOTE: Do NOT mark event.agentId ('main') as shutdown - SubagentStop
499
+ // means a SUBAGENT stopped, not the main agent.
500
+ }
501
+ // Detect Task tool -> create virtual subagent in Agent Map
502
+ if (event.tool === "Task" &&
503
+ event.hookType === "PostToolUse" &&
504
+ event.metadata) {
505
+ try {
506
+ const rawInput = event.metadata["tool_input"];
507
+ const input = typeof rawInput === "string"
508
+ ? JSON.parse(rawInput)
509
+ : rawInput;
510
+ if (input && typeof input === "object") {
511
+ // Prioritize the `name` field (official agent name like "sprint-dev", "researcher").
512
+ // Do NOT use `description` as agent name -- it is a task description, not an agent identifier.
513
+ const name = input["name"] ||
514
+ "subagent";
515
+ const type = input["subagent_type"] || "general-purpose";
516
+ const agentId = `subagent-${name.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase()}`;
517
+ // Create virtual agent in DB
518
+ const existingVirtualAgent = agentQueries
519
+ .getById()
520
+ .get(agentId, event.sessionId);
521
+ if (!existingVirtualAgent) {
522
+ agentQueries
523
+ .upsert()
524
+ .run(agentId, event.sessionId, name, type, "active", event.timestamp, event.timestamp);
525
+ const agents = agentQueries
526
+ .getBySession()
527
+ .all(event.sessionId);
528
+ sessionQueries.updateAgentCount().run(agents.length, event.sessionId);
529
+ sseManager.broadcast("agent_created", {
530
+ agent: agentId,
531
+ sessionId: event.sessionId,
532
+ name,
533
+ type,
534
+ status: "active",
535
+ timestamp: event.timestamp,
536
+ }, event.sessionId);
537
+ }
538
+ else {
539
+ // Reactivate if agent was previously shutdown (re-used name)
540
+ agentQueries
541
+ .updateStatus()
542
+ .run("active", now, agentId, event.sessionId);
543
+ sseManager.broadcast("agent_status", {
544
+ agent: agentId,
545
+ sessionId: event.sessionId,
546
+ status: "active",
547
+ }, event.sessionId);
548
+ }
549
+ // Queue agent name for correlation with incoming SessionStart
550
+ // When a new session appears, it will be assigned this name
551
+ if (name !== "subagent") {
552
+ pendingAgentNames.push(name);
553
+ // Retroactive update: if SubagentStart already arrived before this
554
+ // PostToolUse, the agent was created with name = id (temporary).
555
+ // Find recently-created agents whose name equals their id and rename them.
556
+ retroactivelyNameAgent(name, event.timestamp);
557
+ }
558
+ // Queue for SubagentStop correlation
559
+ if (!spawnedSubagentQueue.has(event.sessionId)) {
560
+ spawnedSubagentQueue.set(event.sessionId, []);
561
+ }
562
+ spawnedSubagentQueue.get(event.sessionId).push(agentId);
563
+ }
564
+ }
565
+ catch {
566
+ // Failed to parse Task tool input
567
+ }
568
+ }
569
+ // Track file changes
570
+ if (event.filePath && event.category === "file_change") {
571
+ const changeType = event.tool === "Write" ? "created" : "modified";
572
+ fileChangeQueries
573
+ .upsert()
574
+ .run(event.filePath, event.sessionId, event.agentId, changeType, event.timestamp, event.timestamp);
575
+ }
576
+ else if (event.filePath &&
577
+ FILE_READ_TOOLS.includes(event.tool || "")) {
578
+ fileChangeQueries
579
+ .upsert()
580
+ .run(event.filePath, event.sessionId, event.agentId, "read", event.timestamp, event.timestamp);
581
+ }
582
+ // Track task items from TaskCreate/TaskUpdate tools
583
+ if (event.tool === "TaskCreate" && event.metadata) {
584
+ const subject = event.metadata["subject"] ||
585
+ event.metadata["tool_input"]?.["subject"] ||
586
+ "Untitled Task";
587
+ const taskId = randomUUID();
588
+ taskItemQueries
589
+ .upsert()
590
+ .run(taskId, event.sessionId, subject, "pending", null, event.timestamp, event.timestamp);
591
+ }
592
+ if (event.tool === "TaskUpdate" && event.metadata) {
593
+ const input = (event.metadata["tool_input"] ?? event.metadata);
594
+ const taskId = input["taskId"] || "";
595
+ const status = input["status"];
596
+ const owner = input["owner"];
597
+ if (taskId) {
598
+ taskItemQueries
599
+ .upsert()
600
+ .run(taskId, event.sessionId, input["subject"] || "Updated Task", status || "pending", owner || null, event.timestamp, event.timestamp);
601
+ }
602
+ // === TaskCompleted Detection (GOLD correlation path) ===
603
+ // When Claude's TaskUpdate marks a task as completed, try to match
604
+ // the subject against PRD tasks for direct auto-completion.
605
+ if (status === "completed") {
606
+ try {
607
+ handleTaskCompleted(event, input);
608
+ }
609
+ catch {
610
+ // TaskCompleted correlation errors must not break event processing
611
+ }
612
+ }
613
+ }
614
+ // Enrich SendMessage metadata with parsed recipient/content
615
+ if (event.tool === "SendMessage" && event.metadata) {
616
+ try {
617
+ const input = (event.metadata["tool_input"] ?? event.metadata);
618
+ const recipient = input["recipient"] ?? input["target_agent_id"];
619
+ const content = input["content"] ?? input["message"];
620
+ const msgType = input["type"];
621
+ if (typeof recipient === "string") {
622
+ event.metadata["_parsed_recipient"] = recipient;
623
+ }
624
+ if (typeof content === "string") {
625
+ event.metadata["_parsed_content"] = content.slice(0, 100);
626
+ }
627
+ if (typeof msgType === "string") {
628
+ event.metadata["_parsed_msg_type"] = msgType;
629
+ }
630
+ }
631
+ catch {
632
+ // skip
633
+ }
634
+ }
635
+ // Detect TeamCreate
636
+ if (event.tool === "TeamCreate" && event.metadata) {
637
+ try {
638
+ const input = (event.metadata["tool_input"] ?? event.metadata);
639
+ const teamName = input["team_name"] ?? input["teamName"];
640
+ if (typeof teamName === "string") {
641
+ sseManager.broadcast("team_created", {
642
+ teamName,
643
+ createdBy: event.agentId,
644
+ sessionId: event.sessionId,
645
+ timestamp: event.timestamp,
646
+ }, event.sessionId);
647
+ }
648
+ }
649
+ catch {
650
+ // skip
651
+ }
652
+ }
653
+ // Persist the event
654
+ eventQueries
655
+ .insert()
656
+ .run(event.id, event.sessionId, event.agentId, event.timestamp, event.hookType, event.category, event.tool || null, event.filePath || null, event.input || null, event.output || null, event.error || null, event.duration || null, event.metadata ? JSON.stringify(event.metadata) : null);
657
+ }
658
+ // ---------------------------------------------------------------------------
659
+ // TaskCompleted Detection - GOLD correlation path
660
+ // ---------------------------------------------------------------------------
661
+ /**
662
+ * When Claude Code's TaskUpdate tool marks a task as "completed", we extract
663
+ * the subject and try to match it directly against PRD task titles.
664
+ * This is the highest-confidence correlation path (confidence 1.0) because
665
+ * the agent explicitly declared the task as done.
666
+ */
667
+ function handleTaskCompleted(event, input) {
668
+ const subject = input["subject"] || "";
669
+ if (!subject || subject === "Updated Task")
670
+ return;
671
+ // Get the project for this session
672
+ const projectId = getProjectForSession(event.sessionId);
673
+ if (!projectId)
674
+ return;
675
+ // Normalize subject for matching: lowercase, strip common prefixes
676
+ const normalizedSubject = subject
677
+ .toLowerCase()
678
+ .replace(/^\[cam:[^\]]*\]\s*/, "")
679
+ .trim();
680
+ if (normalizedSubject.length < 5)
681
+ return;
682
+ // Strategy 1: Exact LIKE match (most reliable)
683
+ const likePattern = `%${normalizedSubject.slice(0, 60)}%`;
684
+ const exactMatches = prdTaskQueries
685
+ .findByTitle()
686
+ .all(projectId, likePattern);
687
+ // Filter to completable tasks (not already completed, deferred, or backlog)
688
+ const completable = exactMatches.filter((t) => {
689
+ const taskStatus = t["status"];
690
+ return (taskStatus !== "completed" &&
691
+ taskStatus !== "deferred" &&
692
+ taskStatus !== "backlog");
693
+ });
694
+ if (completable.length === 1) {
695
+ // Single match = high confidence, auto-complete
696
+ const task = completable[0];
697
+ const taskId = task["id"];
698
+ const taskTitle = task["title"];
699
+ const reason = `TaskCompleted GOLD path: agent ${event.agentId} marked "${subject}" as completed (matched PRD task "${taskTitle}")`;
700
+ markPrdTaskCompleted(taskId, reason);
701
+ // Create high-confidence binding for audit trail
702
+ agentTaskBindingQueries
703
+ .bind()
704
+ .run(randomUUID(), event.agentId, event.sessionId, taskId, 1.0, event.timestamp);
705
+ // Broadcast task completion
706
+ sseManager.broadcast("task_completed", {
707
+ taskId,
708
+ taskTitle,
709
+ agentId: event.agentId,
710
+ sessionId: event.sessionId,
711
+ source: "gold_path",
712
+ confidence: 1.0,
713
+ });
714
+ console.log(`[task-completed] GOLD: "${subject}" -> PRD task "${taskTitle}" (${taskId})`);
715
+ return;
716
+ }
717
+ // Strategy 2: If multiple matches, try exact title match
718
+ if (completable.length > 1) {
719
+ const exactTitleMatch = completable.find((t) => t["title"].toLowerCase() === normalizedSubject);
720
+ if (exactTitleMatch) {
721
+ const taskId = exactTitleMatch["id"];
722
+ const taskTitle = exactTitleMatch["title"];
723
+ const reason = `TaskCompleted GOLD path (exact title): agent ${event.agentId} completed "${subject}"`;
724
+ markPrdTaskCompleted(taskId, reason);
725
+ agentTaskBindingQueries
726
+ .bind()
727
+ .run(randomUUID(), event.agentId, event.sessionId, taskId, 1.0, event.timestamp);
728
+ sseManager.broadcast("task_completed", {
729
+ taskId,
730
+ taskTitle,
731
+ agentId: event.agentId,
732
+ sessionId: event.sessionId,
733
+ source: "gold_path_exact",
734
+ confidence: 1.0,
735
+ });
736
+ console.log(`[task-completed] GOLD (exact): "${subject}" -> PRD task "${taskTitle}" (${taskId})`);
737
+ }
738
+ }
739
+ }
740
+ // cleanupStaleSessions REMOVED: sessions are now only completed by explicit
741
+ // SessionEnd hooks, not by inactivity timeouts.
742
+ // ---------------------------------------------------------------------------
743
+ // Agent Naming Helpers
744
+ // ---------------------------------------------------------------------------
745
+ /** Generic agent types not useful as display names. */
746
+ const GENERIC_AGENT_TYPES = new Set([
747
+ "general-purpose", "general_purpose", "default", "agent", "unknown", "",
748
+ ]);
749
+ /** Names that indicate the agent has no real name (placeholder patterns). */
750
+ const PLACEHOLDER_NAME_PATTERN = /^(agent-\d+|subagent|unknown|Subagent(?: #\d+)?)$/i;
751
+ /**
752
+ * Format a Claude model ID into a human-readable name.
753
+ * "claude-opus-4-6" → "Opus 4.6"
754
+ * "claude-sonnet-4-6" → "Sonnet 4.6"
755
+ * "claude-haiku-4-5-20251001" → "Haiku 4.5"
756
+ */
757
+ function formatModelName(modelId) {
758
+ if (!modelId)
759
+ return null;
760
+ const match = modelId.match(/claude-(\w+)-(\d+)-(\d+)/);
761
+ if (match) {
762
+ const [, family, major, minor] = match;
763
+ return `${family.charAt(0).toUpperCase() + family.slice(1)} ${major}.${minor}`;
764
+ }
765
+ return null; // Return null if unrecognizable (don't leak raw model strings)
766
+ }
767
+ /**
768
+ * Check if a string looks like an auto-generated ID (UUID or hex hash).
769
+ */
770
+ function isLikelyId(value) {
771
+ const trimmed = value.trim();
772
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed))
773
+ return true;
774
+ if (/^[0-9a-f]{8,}$/i.test(trimmed))
775
+ return true;
776
+ return false;
777
+ }
778
+ /**
779
+ * Generate a deduplicated agent name for a session.
780
+ * Uses agent_type if descriptive, model name if available, else "Subagent".
781
+ * Appends "#2", "#3" etc. when multiple agents share the same base name.
782
+ * NEVER returns a UUID, hex string, or "agent-N".
783
+ */
784
+ function generateAgentName(agentType, modelName, sessionId) {
785
+ const trimmedType = agentType.toLowerCase().trim();
786
+ // Determine base name: descriptive type > model name > "Subagent"
787
+ let baseName;
788
+ if (!GENERIC_AGENT_TYPES.has(trimmedType) && agentType.trim() && !isLikelyId(agentType.trim())) {
789
+ baseName = agentType.trim();
790
+ }
791
+ else if (modelName) {
792
+ baseName = modelName;
793
+ }
794
+ else {
795
+ baseName = "Subagent";
796
+ }
797
+ // Deduplicate: track how many agents in this session use this base name
798
+ const key = `${sessionId}::${baseName.toLowerCase()}`;
799
+ const currentCount = agentNameCounts.get(key) ?? 0;
800
+ agentNameCounts.set(key, currentCount + 1);
801
+ if (currentCount === 0) {
802
+ return baseName;
803
+ }
804
+ return `${baseName} #${currentCount + 1}`;
805
+ }
806
+ // ---------------------------------------------------------------------------
807
+ // Retroactive Agent Naming (Task 1: SubagentStart vs PostToolUse race)
808
+ // ---------------------------------------------------------------------------
809
+ /**
810
+ * When PostToolUse for the Task tool arrives with a real agent name,
811
+ * check if there are recently-created agents whose name equals their ID
812
+ * (meaning SubagentStart arrived first and the agent was given a temporary name).
813
+ * If found, update the agent's name retroactively.
814
+ */
815
+ function retroactivelyNameAgent(realName, eventTimestamp) {
816
+ try {
817
+ const cutoff = new Date(new Date(eventTimestamp).getTime() - RETROACTIVE_NAME_WINDOW_MS).toISOString();
818
+ const unnamedAgents = agentQueries
819
+ .getRecentUnnamed()
820
+ .all(cutoff);
821
+ if (unnamedAgents.length === 0)
822
+ return;
823
+ // Pick the most recently created unnamed agent (first in DESC order)
824
+ const agent = unnamedAgents[0];
825
+ const agentId = agent["id"];
826
+ const sessionId = agent["session_id"];
827
+ // Update the agent name
828
+ agentQueries.updateAgentName().run(realName, agentId, sessionId);
829
+ // Also consume the pending name we just pushed (avoid double-assignment)
830
+ const idx = pendingAgentNames.indexOf(realName);
831
+ if (idx !== -1) {
832
+ pendingAgentNames.splice(idx, 1);
833
+ }
834
+ // Broadcast the name update so the dashboard reflects it
835
+ sseManager.broadcast("agent_renamed", {
836
+ agent: agentId,
837
+ sessionId,
838
+ oldName: agentId,
839
+ newName: realName,
840
+ }, sessionId);
841
+ // Cross-broadcast to project peers
842
+ const projectId = getProjectForSession(sessionId);
843
+ if (projectId) {
844
+ broadcastToProjectExcluding(projectId, "agent_renamed", {
845
+ agent: agentId,
846
+ sessionId,
847
+ oldName: agentId,
848
+ newName: realName,
849
+ }, sessionId);
850
+ }
851
+ console.log(`[agent-naming] Retroactively renamed agent ${agentId} (session: ${sessionId}) to "${realName}"`);
852
+ }
853
+ catch {
854
+ // Naming errors should not break event processing
855
+ }
856
+ }
857
+ //# sourceMappingURL=event-processor.js.map