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.
Files changed (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. 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
+ }