ai-agent-session-center 2.0.2 → 2.0.3
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/README.md +484 -429
- package/docs/3D/ADAPTATION_GUIDE.md +592 -0
- package/docs/3D/index.html +754 -0
- package/docs/AGENT_TEAM_TASKS.md +716 -0
- package/docs/CYBERDROME_V2_SPEC.md +531 -0
- package/docs/ERROR_185_ANALYSIS.md +263 -0
- package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
- package/docs/SESSION_DETAIL_FEATURES.md +98 -0
- package/docs/_3d_multimedia_features.md +1080 -0
- package/docs/_frontend_features.md +1057 -0
- package/docs/_server_features.md +1077 -0
- package/docs/session-duplication-fixes.md +271 -0
- package/docs/session-terminal-linkage.md +412 -0
- package/package.json +63 -5
- package/public/apple-touch-icon.svg +21 -0
- package/public/css/dashboard.css +0 -161
- package/public/css/detail-panel.css +25 -0
- package/public/css/layout.css +18 -1
- package/public/css/modals.css +0 -26
- package/public/css/settings.css +0 -150
- package/public/css/terminal.css +34 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +6 -26
- package/public/js/alarmManager.js +0 -21
- package/public/js/app.js +21 -7
- package/public/js/detailPanel.js +63 -64
- package/public/js/historyPanel.js +61 -55
- package/public/js/quickActions.js +132 -48
- package/public/js/sessionCard.js +5 -20
- package/public/js/sessionControls.js +8 -0
- package/public/js/settingsManager.js +0 -142
- package/server/apiRouter.js +60 -15
- package/server/apiRouter.ts +774 -0
- package/server/approvalDetector.ts +94 -0
- package/server/authManager.ts +144 -0
- package/server/autoIdleManager.ts +110 -0
- package/server/config.ts +121 -0
- package/server/constants.ts +150 -0
- package/server/db.ts +475 -0
- package/server/hookInstaller.d.ts +3 -0
- package/server/hookProcessor.ts +108 -0
- package/server/hookRouter.ts +18 -0
- package/server/hookStats.ts +116 -0
- package/server/index.js +15 -1
- package/server/index.ts +230 -0
- package/server/logger.ts +75 -0
- package/server/mqReader.ts +349 -0
- package/server/portManager.ts +55 -0
- package/server/processMonitor.ts +239 -0
- package/server/serverConfig.ts +29 -0
- package/server/sessionMatcher.js +17 -6
- package/server/sessionMatcher.ts +403 -0
- package/server/sessionStore.js +109 -3
- package/server/sessionStore.ts +1145 -0
- package/server/sshManager.js +167 -24
- package/server/sshManager.ts +671 -0
- package/server/teamManager.ts +289 -0
- package/server/wsManager.ts +200 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module teamManager
|
|
3
|
+
* Manages team/subagent hierarchies. Teams are auto-created when a SubagentStart event
|
|
4
|
+
* matches a new session by working directory, or directly linked via CLAUDE_CODE_PARENT_SESSION_ID
|
|
5
|
+
* env var (Priority 0). Tracks parent-child relationships and handles cleanup when team
|
|
6
|
+
* members end (with 15s delay for the parent).
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import log from './logger.js';
|
|
12
|
+
import { SESSION_STATUS } from './constants.js';
|
|
13
|
+
import type { Session } from '../src/types/session.js';
|
|
14
|
+
import type { Team, TeamSerialized, PendingSubagent, TeamConfig, TeamLinkResult } from '../src/types/team.js';
|
|
15
|
+
|
|
16
|
+
const teams = new Map<string, Team>(); // teamId -> Team
|
|
17
|
+
const sessionToTeam = new Map<string, string>(); // sessionId -> teamId
|
|
18
|
+
const pendingSubagents: PendingSubagent[] = []; // { parentSessionId, parentCwd, agentType, timestamp }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find a pending subagent entry matching a new child session.
|
|
22
|
+
* Matches by cwd (exact or parent/child path relationship).
|
|
23
|
+
*/
|
|
24
|
+
export function findPendingSubagentMatch(
|
|
25
|
+
childSessionId: string,
|
|
26
|
+
childCwd: string,
|
|
27
|
+
sessions: Map<string, Session>,
|
|
28
|
+
): TeamLinkResult | null {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
// Clean stale entries (>10s old)
|
|
31
|
+
while (pendingSubagents.length > 0 && now - pendingSubagents[0].timestamp > 10000) {
|
|
32
|
+
pendingSubagents.shift();
|
|
33
|
+
}
|
|
34
|
+
if (!childCwd || pendingSubagents.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
// Match by cwd — exact match or parent/child path relationship
|
|
37
|
+
for (let i = pendingSubagents.length - 1; i >= 0; i--) {
|
|
38
|
+
const pending = pendingSubagents[i];
|
|
39
|
+
if (pending.parentSessionId === childSessionId) continue; // skip self
|
|
40
|
+
const parentCwd = pending.parentCwd;
|
|
41
|
+
if (parentCwd && (childCwd === parentCwd || childCwd.startsWith(parentCwd + '/') || parentCwd.startsWith(childCwd + '/'))) {
|
|
42
|
+
// Found match — consume it
|
|
43
|
+
pendingSubagents.splice(i, 1);
|
|
44
|
+
return linkSessionToTeam(pending.parentSessionId, childSessionId, pending.agentType, sessions);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Directly link a child session to its parent using CLAUDE_CODE_PARENT_SESSION_ID.
|
|
52
|
+
* This is Priority 0 matching — no path guessing needed.
|
|
53
|
+
*/
|
|
54
|
+
export function linkByParentSessionId(
|
|
55
|
+
childSessionId: string,
|
|
56
|
+
parentSessionId: string,
|
|
57
|
+
agentType: string,
|
|
58
|
+
agentName: string | null,
|
|
59
|
+
teamName: string | null,
|
|
60
|
+
sessions: Map<string, Session>,
|
|
61
|
+
): TeamLinkResult | null {
|
|
62
|
+
if (!parentSessionId || !childSessionId) return null;
|
|
63
|
+
if (parentSessionId === childSessionId) return null;
|
|
64
|
+
|
|
65
|
+
const parentSession = sessions.get(parentSessionId);
|
|
66
|
+
if (!parentSession) {
|
|
67
|
+
log.debug('session', `linkByParentSessionId: parent ${parentSessionId?.slice(0, 8)} not found in sessions`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = linkSessionToTeam(parentSessionId, childSessionId, agentType, sessions);
|
|
72
|
+
|
|
73
|
+
// Apply team name from env var if available
|
|
74
|
+
if (teamName && result) {
|
|
75
|
+
const team = teams.get(result.teamId);
|
|
76
|
+
if (team) {
|
|
77
|
+
team.teamName = teamName;
|
|
78
|
+
result.team = serializeTeam(team)!;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Store agent name on the child session
|
|
83
|
+
const childSession = sessions.get(childSessionId);
|
|
84
|
+
if (childSession && agentName) {
|
|
85
|
+
childSession.agentName = agentName;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Try to read team config for additional member metadata
|
|
89
|
+
const effectiveTeamName = teamName || (result ? teams.get(result.teamId)?.teamName : null);
|
|
90
|
+
if (effectiveTeamName && childSession) {
|
|
91
|
+
const config = readTeamConfig(effectiveTeamName);
|
|
92
|
+
if (config && agentName && config.members) {
|
|
93
|
+
const memberConfig = config.members[agentName];
|
|
94
|
+
if (memberConfig) {
|
|
95
|
+
if (memberConfig.tmuxPaneId) childSession.tmuxPaneId = memberConfig.tmuxPaneId;
|
|
96
|
+
if (memberConfig.backendType) childSession.backendType = memberConfig.backendType;
|
|
97
|
+
if (memberConfig.color) childSession.agentColor = memberConfig.color;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log.info('session', `linkByParentSessionId: ${childSessionId?.slice(0, 8)} -> parent ${parentSessionId?.slice(0, 8)} (agent=${agentName || 'unknown'}, team=${teamName || 'auto'})`);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read team configuration from ~/.claude/teams/{teamName}/config.json.
|
|
108
|
+
* Returns null if file doesn't exist or is invalid.
|
|
109
|
+
*/
|
|
110
|
+
export function readTeamConfig(teamName: string): TeamConfig | null {
|
|
111
|
+
if (!teamName || typeof teamName !== 'string') return null;
|
|
112
|
+
// Sanitize team name to prevent path traversal
|
|
113
|
+
const safeName = teamName.replace(/[^a-zA-Z0-9_\-. ]/g, '');
|
|
114
|
+
if (!safeName) return null;
|
|
115
|
+
|
|
116
|
+
const configPath = join(homedir(), '.claude', 'teams', safeName, 'config.json');
|
|
117
|
+
try {
|
|
118
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
119
|
+
return JSON.parse(raw) as TeamConfig;
|
|
120
|
+
} catch {
|
|
121
|
+
// Config file doesn't exist or is invalid — that's fine
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Link a child session to a team (creating the team if needed).
|
|
128
|
+
*/
|
|
129
|
+
function linkSessionToTeam(
|
|
130
|
+
parentId: string,
|
|
131
|
+
childId: string,
|
|
132
|
+
agentType: string,
|
|
133
|
+
sessions: Map<string, Session>,
|
|
134
|
+
): TeamLinkResult {
|
|
135
|
+
const teamId = `team-${parentId}`;
|
|
136
|
+
let team = teams.get(teamId);
|
|
137
|
+
|
|
138
|
+
if (!team) {
|
|
139
|
+
team = {
|
|
140
|
+
teamId,
|
|
141
|
+
parentSessionId: parentId,
|
|
142
|
+
childSessionIds: new Set<string>(),
|
|
143
|
+
teamName: null,
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
};
|
|
146
|
+
teams.set(teamId, team);
|
|
147
|
+
|
|
148
|
+
// Set team name from parent's project name
|
|
149
|
+
const parentSession = sessions.get(parentId);
|
|
150
|
+
if (parentSession) {
|
|
151
|
+
team.teamName = `${parentSession.projectName} Team`;
|
|
152
|
+
parentSession.teamId = teamId;
|
|
153
|
+
parentSession.teamRole = 'leader';
|
|
154
|
+
sessionToTeam.set(parentId, teamId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Link child
|
|
159
|
+
team.childSessionIds.add(childId);
|
|
160
|
+
const childSession = sessions.get(childId);
|
|
161
|
+
if (childSession) {
|
|
162
|
+
childSession.teamId = teamId;
|
|
163
|
+
childSession.teamRole = 'member';
|
|
164
|
+
childSession.agentType = agentType;
|
|
165
|
+
}
|
|
166
|
+
sessionToTeam.set(childId, teamId);
|
|
167
|
+
|
|
168
|
+
log.info('session', `Linked session ${childId} to team ${teamId} as ${agentType}`);
|
|
169
|
+
return { teamId, team: serializeTeam(team)! };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Handle team cleanup when a member session ends.
|
|
174
|
+
*/
|
|
175
|
+
export function handleTeamMemberEnd(
|
|
176
|
+
sessionId: string,
|
|
177
|
+
sessions: Map<string, Session>,
|
|
178
|
+
): TeamLinkResult | null {
|
|
179
|
+
const teamId = sessionToTeam.get(sessionId);
|
|
180
|
+
if (!teamId) return null;
|
|
181
|
+
|
|
182
|
+
const team = teams.get(teamId);
|
|
183
|
+
if (!team) return null;
|
|
184
|
+
|
|
185
|
+
team.childSessionIds.delete(sessionId);
|
|
186
|
+
sessionToTeam.delete(sessionId);
|
|
187
|
+
|
|
188
|
+
// If parent ended and all children ended, clean up the team
|
|
189
|
+
if (sessionId === team.parentSessionId) {
|
|
190
|
+
const allChildrenEnded = [...team.childSessionIds].every(cid => {
|
|
191
|
+
const s = sessions.get(cid);
|
|
192
|
+
return !s || s.status === SESSION_STATUS.ENDED;
|
|
193
|
+
});
|
|
194
|
+
if (allChildrenEnded) {
|
|
195
|
+
// Clean up team after a delay
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
teams.delete(teamId);
|
|
198
|
+
sessionToTeam.delete(team.parentSessionId);
|
|
199
|
+
for (const cid of team.childSessionIds) {
|
|
200
|
+
sessionToTeam.delete(cid);
|
|
201
|
+
}
|
|
202
|
+
}, 15000);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { teamId, team: serializeTeam(team)! };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Add a pending subagent entry for team auto-detection.
|
|
211
|
+
*/
|
|
212
|
+
export function addPendingSubagent(
|
|
213
|
+
parentSessionId: string,
|
|
214
|
+
parentCwd: string,
|
|
215
|
+
agentType: string | undefined,
|
|
216
|
+
agentId: string | undefined,
|
|
217
|
+
): void {
|
|
218
|
+
pendingSubagents.push({
|
|
219
|
+
parentSessionId,
|
|
220
|
+
parentCwd,
|
|
221
|
+
agentType: agentType || 'unknown',
|
|
222
|
+
agentId: agentId || null,
|
|
223
|
+
timestamp: Date.now(),
|
|
224
|
+
});
|
|
225
|
+
// Prune stale entries (>30s old)
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
while (pendingSubagents.length > 0 && now - pendingSubagents[0].timestamp > 30000) {
|
|
228
|
+
pendingSubagents.shift();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function serializeTeam(team: Team): TeamSerialized | null {
|
|
233
|
+
if (!team) return null;
|
|
234
|
+
return {
|
|
235
|
+
teamId: team.teamId,
|
|
236
|
+
parentSessionId: team.parentSessionId,
|
|
237
|
+
childSessionIds: [...team.childSessionIds],
|
|
238
|
+
teamName: team.teamName,
|
|
239
|
+
createdAt: team.createdAt,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getTeam(teamId: string): TeamSerialized | null {
|
|
244
|
+
const team = teams.get(teamId);
|
|
245
|
+
return team ? serializeTeam(team) : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function getAllTeams(): Record<string, TeamSerialized> {
|
|
249
|
+
const result: Record<string, TeamSerialized> = {};
|
|
250
|
+
for (const [id, team] of teams) {
|
|
251
|
+
const serialized = serializeTeam(team);
|
|
252
|
+
if (serialized) result[id] = serialized;
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function getTeamForSession(sessionId: string): TeamSerialized | null {
|
|
258
|
+
const teamId = sessionToTeam.get(sessionId);
|
|
259
|
+
if (!teamId) return null;
|
|
260
|
+
return getTeam(teamId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function getTeamIdForSession(sessionId: string): string | null {
|
|
264
|
+
return sessionToTeam.get(sessionId) || null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the tmux pane ID for a team member session.
|
|
269
|
+
* Looks up the session's stored tmuxPaneId field (set during linkByParentSessionId).
|
|
270
|
+
*/
|
|
271
|
+
export function getMemberTmuxPaneId(
|
|
272
|
+
teamId: string,
|
|
273
|
+
sessionId: string,
|
|
274
|
+
sessions: Map<string, Session>,
|
|
275
|
+
): string | null {
|
|
276
|
+
const team = teams.get(teamId);
|
|
277
|
+
if (!team) return null;
|
|
278
|
+
if (sessionId !== team.parentSessionId && !team.childSessionIds.has(sessionId)) return null;
|
|
279
|
+
const session = sessions.get(sessionId);
|
|
280
|
+
return session?.tmuxPaneId || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get the config file path for a team.
|
|
285
|
+
*/
|
|
286
|
+
export function getTeamConfigPath(teamName: string): string {
|
|
287
|
+
const safeName = (teamName || '').replace(/[^a-zA-Z0-9_\-. ]/g, '');
|
|
288
|
+
return join(homedir(), '.claude', 'teams', safeName, 'config.json');
|
|
289
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// wsManager.ts — WebSocket broadcast manager with bidirectional terminal support
|
|
2
|
+
import { getAllSessions, getAllTeams, getEventSeq, getEventsSince, updateQueueCount } from './sessionStore.js';
|
|
3
|
+
import { writeToTerminal, resizeTerminal, closeTerminal, setWsClient } from './sshManager.js';
|
|
4
|
+
import { WS_TYPES } from './constants.js';
|
|
5
|
+
import log from './logger.js';
|
|
6
|
+
import type WebSocket from 'ws';
|
|
7
|
+
|
|
8
|
+
interface WsClient extends WebSocket {
|
|
9
|
+
_terminalIds: Set<string>;
|
|
10
|
+
_isAlive: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const clients = new Set<WsClient>();
|
|
14
|
+
|
|
15
|
+
// Heartbeat: ping every 30s, terminate connections that don't pong within 10s
|
|
16
|
+
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
17
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
18
|
+
|
|
19
|
+
// Backpressure: skip non-critical updates if client buffer exceeds 1MB
|
|
20
|
+
const MAX_BUFFERED_AMOUNT = 1 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
// Throttle hook_stats broadcasts to once per second max
|
|
23
|
+
let lastHookStatsBroadcastAt = 0;
|
|
24
|
+
let pendingHookStats: unknown = null;
|
|
25
|
+
let hookStatsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
+
const HOOK_STATS_THROTTLE_MS = 1000;
|
|
27
|
+
|
|
28
|
+
function startHeartbeat(): void {
|
|
29
|
+
if (heartbeatTimer) return;
|
|
30
|
+
heartbeatTimer = setInterval(() => {
|
|
31
|
+
for (const ws of clients) {
|
|
32
|
+
if (ws._isAlive === false) {
|
|
33
|
+
// Didn't respond to last ping — terminate
|
|
34
|
+
log.info('ws', 'Terminating unresponsive client');
|
|
35
|
+
ws.terminate();
|
|
36
|
+
clients.delete(ws);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
ws._isAlive = false;
|
|
40
|
+
ws.ping();
|
|
41
|
+
}
|
|
42
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function stopHeartbeat(): void {
|
|
46
|
+
if (heartbeatTimer) {
|
|
47
|
+
clearInterval(heartbeatTimer);
|
|
48
|
+
heartbeatTimer = null;
|
|
49
|
+
}
|
|
50
|
+
if (hookStatsTimer) {
|
|
51
|
+
clearTimeout(hookStatsTimer);
|
|
52
|
+
hookStatsTimer = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handle a new WebSocket connection: send snapshot and wire up message/close handlers.
|
|
58
|
+
*/
|
|
59
|
+
export function handleConnection(ws: WebSocket): void {
|
|
60
|
+
const client = ws as WsClient;
|
|
61
|
+
clients.add(client);
|
|
62
|
+
client._terminalIds = new Set();
|
|
63
|
+
client._isAlive = true;
|
|
64
|
+
log.info('ws', `Client connected (total: ${clients.size})`);
|
|
65
|
+
|
|
66
|
+
// Start heartbeat on first connection
|
|
67
|
+
startHeartbeat();
|
|
68
|
+
|
|
69
|
+
// Handle pong responses
|
|
70
|
+
client.on('pong', () => {
|
|
71
|
+
client._isAlive = true;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Send full snapshot on connect (includes teams + event sequence for replay)
|
|
75
|
+
const sessions = getAllSessions();
|
|
76
|
+
const teams = getAllTeams();
|
|
77
|
+
const seq = getEventSeq();
|
|
78
|
+
log.debug('ws', `Sending snapshot: ${Object.keys(sessions).length} sessions, ${Object.keys(teams).length} teams, seq=${seq}`);
|
|
79
|
+
client.send(JSON.stringify({ type: WS_TYPES.SNAPSHOT, sessions, teams, seq }));
|
|
80
|
+
|
|
81
|
+
// Handle incoming messages (terminal input, resize, etc.)
|
|
82
|
+
client.on('message', (raw: WebSocket.RawData) => {
|
|
83
|
+
try {
|
|
84
|
+
const msg = JSON.parse(raw.toString());
|
|
85
|
+
switch (msg.type) {
|
|
86
|
+
case WS_TYPES.TERMINAL_INPUT:
|
|
87
|
+
if (msg.terminalId && msg.data) {
|
|
88
|
+
writeToTerminal(msg.terminalId, msg.data);
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
case WS_TYPES.TERMINAL_RESIZE:
|
|
92
|
+
if (msg.terminalId && msg.cols && msg.rows) {
|
|
93
|
+
resizeTerminal(msg.terminalId, msg.cols, msg.rows);
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case WS_TYPES.TERMINAL_DISCONNECT:
|
|
97
|
+
if (msg.terminalId) {
|
|
98
|
+
closeTerminal(msg.terminalId);
|
|
99
|
+
client._terminalIds.delete(msg.terminalId);
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
case WS_TYPES.TERMINAL_SUBSCRIBE:
|
|
103
|
+
if (msg.terminalId) {
|
|
104
|
+
client._terminalIds.add(msg.terminalId);
|
|
105
|
+
setWsClient(msg.terminalId, client);
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case WS_TYPES.UPDATE_QUEUE_COUNT:
|
|
109
|
+
if (msg.sessionId != null && msg.count != null) {
|
|
110
|
+
const updated = updateQueueCount(msg.sessionId, msg.count);
|
|
111
|
+
if (updated) {
|
|
112
|
+
broadcast({ type: WS_TYPES.SESSION_UPDATE, session: updated });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
case WS_TYPES.REPLAY:
|
|
117
|
+
// Client reconnected and wants events since a certain sequence number
|
|
118
|
+
if (typeof msg.sinceSeq === 'number') {
|
|
119
|
+
const missed = getEventsSince(msg.sinceSeq);
|
|
120
|
+
log.debug('ws', `Replaying ${missed.length} events since seq=${msg.sinceSeq}`);
|
|
121
|
+
for (const evt of missed) {
|
|
122
|
+
client.send(JSON.stringify(evt.data));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
default:
|
|
127
|
+
log.debug('ws', `Unknown message type: ${msg.type}`);
|
|
128
|
+
}
|
|
129
|
+
} catch (e: unknown) {
|
|
130
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
131
|
+
log.debug('ws', `Invalid WS message: ${msg}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
client.on('close', () => {
|
|
136
|
+
clients.delete(client);
|
|
137
|
+
log.info('ws', `Client disconnected (total: ${clients.size})`);
|
|
138
|
+
// Stop heartbeat if no clients remain
|
|
139
|
+
if (clients.size === 0) {
|
|
140
|
+
stopHeartbeat();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
client.on('error', (err: Error) => {
|
|
144
|
+
clients.delete(client);
|
|
145
|
+
log.error('ws', 'Client error:', err.message);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if a broadcast type is critical (must not be skipped under backpressure).
|
|
151
|
+
* Session updates and snapshots are critical; hook_stats are not.
|
|
152
|
+
*/
|
|
153
|
+
function isCriticalBroadcast(data: { type: string }): boolean {
|
|
154
|
+
return data.type !== WS_TYPES.HOOK_STATS;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Broadcast a message to all connected WebSocket clients.
|
|
159
|
+
* Throttles hook_stats to once per second; applies backpressure for non-critical messages.
|
|
160
|
+
*/
|
|
161
|
+
export function broadcast(data: { type: string; [key: string]: unknown }): void {
|
|
162
|
+
// Throttle hook_stats broadcasts to once per second max
|
|
163
|
+
if (data.type === WS_TYPES.HOOK_STATS) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (now - lastHookStatsBroadcastAt < HOOK_STATS_THROTTLE_MS) {
|
|
166
|
+
// Store for deferred send
|
|
167
|
+
pendingHookStats = data;
|
|
168
|
+
if (!hookStatsTimer) {
|
|
169
|
+
hookStatsTimer = setTimeout(() => {
|
|
170
|
+
hookStatsTimer = null;
|
|
171
|
+
if (pendingHookStats) {
|
|
172
|
+
const deferred = pendingHookStats as { type: string; [key: string]: unknown };
|
|
173
|
+
pendingHookStats = null;
|
|
174
|
+
lastHookStatsBroadcastAt = Date.now();
|
|
175
|
+
broadcastToClients(deferred, false);
|
|
176
|
+
}
|
|
177
|
+
}, HOOK_STATS_THROTTLE_MS - (now - lastHookStatsBroadcastAt));
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
lastHookStatsBroadcastAt = now;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const critical = isCriticalBroadcast(data);
|
|
185
|
+
broadcastToClients(data, critical);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function broadcastToClients(data: { type: string; [key: string]: unknown }, critical: boolean): void {
|
|
189
|
+
const msg = JSON.stringify(data);
|
|
190
|
+
log.debug('ws', `Broadcasting ${data.type} to ${clients.size} clients`);
|
|
191
|
+
for (const client of clients) {
|
|
192
|
+
if (client.readyState !== 1) continue;
|
|
193
|
+
// Backpressure: skip non-critical updates if buffer is too large
|
|
194
|
+
if (!critical && client.bufferedAmount > MAX_BUFFERED_AMOUNT) {
|
|
195
|
+
log.debug('ws', `Skipping ${data.type} for client (buffered=${client.bufferedAmount})`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
client.send(msg);
|
|
199
|
+
}
|
|
200
|
+
}
|