@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.
- package/dist/db/index.js +68 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/queries.js +658 -0
- package/dist/db/queries.js.map +1 -0
- package/dist/db/schema.sql +259 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/agents.js +68 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/correlation-audit.js +31 -0
- package/dist/routes/correlation-audit.js.map +1 -0
- package/dist/routes/events.js +81 -0
- package/dist/routes/events.js.map +1 -0
- package/dist/routes/files.js +24 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/parse-prd.js +38 -0
- package/dist/routes/parse-prd.js.map +1 -0
- package/dist/routes/projects.js +96 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/registry.js +88 -0
- package/dist/routes/registry.js.map +1 -0
- package/dist/routes/session-groups.js +182 -0
- package/dist/routes/session-groups.js.map +1 -0
- package/dist/routes/sessions.js +109 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sprints.js +58 -0
- package/dist/routes/sprints.js.map +1 -0
- package/dist/routes/stats.js +63 -0
- package/dist/routes/stats.js.map +1 -0
- package/dist/routes/stream.js +21 -0
- package/dist/routes/stream.js.map +1 -0
- package/dist/routes/tasks.js +198 -0
- package/dist/routes/tasks.js.map +1 -0
- package/dist/services/correlation-engine.js +577 -0
- package/dist/services/correlation-engine.js.map +1 -0
- package/dist/services/event-processor.js +857 -0
- package/dist/services/event-processor.js.map +1 -0
- package/dist/services/prd-parser.js +142 -0
- package/dist/services/prd-parser.js.map +1 -0
- package/dist/services/project-manager.js +351 -0
- package/dist/services/project-manager.js.map +1 -0
- package/dist/services/project-router.js +56 -0
- package/dist/services/project-router.js.map +1 -0
- package/dist/services/session-manager.js +76 -0
- package/dist/services/session-manager.js.map +1 -0
- package/dist/services/sse-manager.js +115 -0
- package/dist/services/sse-manager.js.map +1 -0
- package/dist/services/string-similarity.js +256 -0
- package/dist/services/string-similarity.js.map +1 -0
- package/dist/services/task-completion.js +251 -0
- package/dist/services/task-completion.js.map +1 -0
- 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
|