ai-agent-session-center 2.3.3 → 2.3.5

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 (27) hide show
  1. package/dist/client/assets/{AnalyticsView-DVDqblSH.js → AnalyticsView-qY6U6hm6.js} +1 -1
  2. package/dist/client/assets/{Charts.module-CQf7HQCb.js → Charts.module-DMrHdYE2.js} +1 -1
  3. package/dist/client/assets/{CyberdromeScene-w4bk6IXJ.js → CyberdromeScene-BAHIVPjO.js} +1 -1
  4. package/dist/client/assets/{HistoryView--XwKlCyd.js → HistoryView-DBPWSMVy.js} +1 -1
  5. package/dist/client/assets/{ProjectBrowserView-CTN5CmBa.js → ProjectBrowserView-BVsrZVHg.js} +1 -1
  6. package/dist/client/assets/{QueueView-Bj-e0m_U.js → QueueView-t313VSQZ.js} +1 -1
  7. package/dist/client/assets/{TimelineView-CoTsTAGd.js → TimelineView-COH0s3pN.js} +1 -1
  8. package/dist/client/assets/index-DcItPQrq.js +130 -0
  9. package/dist/client/assets/index-b03MoG49.css +1 -0
  10. package/dist/client/assets/{useQuery-5GNo2Ewt.js → useQuery-C6BUH11S.js} +1 -1
  11. package/dist/client/assets/{with-selector-DTnjuyBc.js → with-selector-Cw2vedS9.js} +1 -1
  12. package/dist/client/index.html +2 -2
  13. package/dist/client/screenshot-mobile-history.png +0 -0
  14. package/dist/client/screenshot-mobile-home.png +0 -0
  15. package/dist/client/screenshot-mobile-project.png +0 -0
  16. package/dist/client/screenshot-mobile-terminal.png +0 -0
  17. package/package.json +1 -1
  18. package/server/apiRouter.ts +145 -30
  19. package/server/approvalDetector.ts +2 -2
  20. package/server/index.ts +51 -18
  21. package/server/mqReader.ts +4 -4
  22. package/server/processMonitor.ts +12 -6
  23. package/server/sessionMatcher.ts +39 -0
  24. package/server/sessionStore.ts +4 -3
  25. package/server/wsManager.ts +51 -10
  26. package/dist/client/assets/index-Dgi6T0Nt.js +0 -128
  27. package/dist/client/assets/index-DqtLpLIs.css +0 -1
@@ -50,6 +50,29 @@ export function reKeyResumedSession(
50
50
  oldSession.cachedPid = null;
51
51
  }
52
52
 
53
+ // Archive the old session data into previousSessions before resetting.
54
+ // Check dedup: resumeSession() already archives before calling this function,
55
+ // so only archive if the last entry doesn't match the old session ID.
56
+ const hasData = oldSession.promptHistory.length > 0 || oldSession.toolLog?.length > 0 || oldSession.events?.length > 0;
57
+ if (hasData) {
58
+ const lastPrev = oldSession.previousSessions?.[oldSession.previousSessions.length - 1];
59
+ if (!lastPrev || lastPrev.sessionId !== oldSessionId) {
60
+ if (!oldSession.previousSessions) oldSession.previousSessions = [];
61
+ oldSession.previousSessions.push({
62
+ sessionId: oldSessionId,
63
+ startedAt: oldSession.startedAt,
64
+ endedAt: oldSession.endedAt,
65
+ promptHistory: [...oldSession.promptHistory],
66
+ toolLog: [...(oldSession.toolLog || [])],
67
+ responseLog: [...(oldSession.responseLog || [])],
68
+ events: [...oldSession.events],
69
+ toolUsage: { ...oldSession.toolUsage },
70
+ totalToolCalls: oldSession.totalToolCalls,
71
+ });
72
+ if (oldSession.previousSessions.length > 5) oldSession.previousSessions.shift();
73
+ }
74
+ }
75
+
53
76
  oldSession.replacesId = oldSessionId;
54
77
  oldSession.sessionId = newSessionId;
55
78
  oldSession.status = SESSION_STATUS.IDLE;
@@ -294,6 +317,22 @@ export function matchSession(
294
317
  }
295
318
  }
296
319
 
320
+ // Priority 1.5: Match by cached PID — when Claude resumes with a new session_id
321
+ // but the same process (e.g., `claude --resume` creates a new session internally),
322
+ // link back to the same SSH terminal session instead of creating a duplicate card.
323
+ if (!session && hookData.claude_pid && hook_event_name === EVENT_TYPES.SESSION_START) {
324
+ const pid = Number(hookData.claude_pid);
325
+ const existingSessionId = pidToSession.get(pid);
326
+ if (existingSessionId && existingSessionId !== session_id) {
327
+ const existingSession = sessions.get(existingSessionId);
328
+ if (existingSession && existingSession.terminalId) {
329
+ session = reKeyResumedSession(sessions, existingSession, session_id, existingSessionId, pidToSession);
330
+ consumePendingLink(existingSession.projectPath || '');
331
+ log.info('session', `Re-keyed session ${existingSessionId?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (via cached PID=${pid}, same process new session_id)`);
332
+ }
333
+ }
334
+ }
335
+
297
336
  // Priority 2: Match via pending workDir link
298
337
  if (!session) {
299
338
  const linkedTerminalId = tryLinkByWorkDir(cwd || '', session_id);
@@ -141,9 +141,9 @@ export function saveSnapshot(mqOffset?: number): void {
141
141
  pidToSession: pidObj,
142
142
  pendingResume: pendingResumeObj,
143
143
  };
144
- mkdirSync(SNAPSHOT_DIR, { recursive: true });
144
+ mkdirSync(SNAPSHOT_DIR, { recursive: true, mode: 0o700 });
145
145
  const tmpFile = SNAPSHOT_FILE + '.tmp';
146
- writeFileSync(tmpFile, JSON.stringify(snapshot));
146
+ writeFileSync(tmpFile, JSON.stringify(snapshot), { mode: 0o600 });
147
147
  renameSync(tmpFile, SNAPSHOT_FILE);
148
148
  log.debug('session', `Snapshot saved: ${Object.keys(sessionsObj).length} sessions`);
149
149
  } catch (err: unknown) {
@@ -770,7 +770,8 @@ export function handleEvent(hookData: HookPayload): HandleEventResult | null {
770
770
  if (session.source === 'ssh') {
771
771
  session.isHistorical = true;
772
772
  session.lastTerminalId = session.terminalId;
773
- session.terminalId = null;
773
+ // Keep terminalId alive — the PTY shell is still running even though Claude exited.
774
+ // terminalId is nulled when the PTY actually dies (registerTerminalExitCallback).
774
775
  }
775
776
  // Non-SSH sessions are also kept (no auto-delete)
776
777
  break;
@@ -8,9 +8,13 @@ import type WebSocket from 'ws';
8
8
  interface WsClient extends WebSocket {
9
9
  _terminalIds: Set<string>;
10
10
  _isAlive: boolean;
11
+ _msgCount: number;
12
+ _msgWindowStart: number;
11
13
  }
12
14
 
13
15
  const clients = new Set<WsClient>();
16
+ const MAX_WS_CONNECTIONS = 50;
17
+ const MAX_MSG_PER_SECOND = 100;
14
18
 
15
19
  // Heartbeat: ping every 30s, terminate connections that don't pong within 10s
16
20
  const HEARTBEAT_INTERVAL_MS = 30000;
@@ -57,10 +61,19 @@ export function stopHeartbeat(): void {
57
61
  * Handle a new WebSocket connection: send snapshot and wire up message/close handlers.
58
62
  */
59
63
  export function handleConnection(ws: WebSocket): void {
64
+ // Enforce connection limit
65
+ if (clients.size >= MAX_WS_CONNECTIONS) {
66
+ log.warn('ws', `Connection limit reached (${MAX_WS_CONNECTIONS}), rejecting`);
67
+ ws.close(4003, 'Too many connections');
68
+ return;
69
+ }
70
+
60
71
  const client = ws as WsClient;
61
72
  clients.add(client);
62
73
  client._terminalIds = new Set();
63
74
  client._isAlive = true;
75
+ client._msgCount = 0;
76
+ client._msgWindowStart = Date.now();
64
77
  log.info('ws', `Client connected (total: ${clients.size})`);
65
78
 
66
79
  // Start heartbeat on first connection
@@ -80,16 +93,43 @@ export function handleConnection(ws: WebSocket): void {
80
93
 
81
94
  // Handle incoming messages (terminal input, resize, etc.)
82
95
  client.on('message', (raw: WebSocket.RawData) => {
96
+ // Rate limit: max MAX_MSG_PER_SECOND messages per second per client
97
+ const now = Date.now();
98
+ if (now - client._msgWindowStart > 1000) {
99
+ client._msgWindowStart = now;
100
+ client._msgCount = 0;
101
+ }
102
+ client._msgCount++;
103
+ if (client._msgCount > MAX_MSG_PER_SECOND) {
104
+ log.warn('ws', 'Client message rate limit exceeded, closing');
105
+ client.close(4004, 'Rate limit exceeded');
106
+ return;
107
+ }
108
+
83
109
  try {
84
- const msg = JSON.parse(raw.toString());
110
+ const rawStr = raw.toString();
111
+ // Reject oversized messages early (64KB)
112
+ if (rawStr.length > 65536) {
113
+ log.warn('ws', 'Oversized WS message rejected');
114
+ return;
115
+ }
116
+ const msg = JSON.parse(rawStr);
85
117
  switch (msg.type) {
86
118
  case WS_TYPES.TERMINAL_INPUT:
87
- if (msg.terminalId && msg.data) {
119
+ // Only allow writing to terminals this client is subscribed to
120
+ if (typeof msg.terminalId === 'string' && typeof msg.data === 'string' && msg.data.length <= 8192) {
121
+ if (!client._terminalIds.has(msg.terminalId)) {
122
+ log.warn('ws', `Blocked terminal_input to unsubscribed terminal ${msg.terminalId}`);
123
+ break;
124
+ }
88
125
  writeToTerminal(msg.terminalId, msg.data);
89
126
  }
90
127
  break;
91
128
  case WS_TYPES.TERMINAL_RESIZE:
92
- if (msg.terminalId && msg.cols && msg.rows) {
129
+ if (typeof msg.terminalId === 'string'
130
+ && Number.isInteger(msg.cols) && msg.cols > 0 && msg.cols <= 500
131
+ && Number.isInteger(msg.rows) && msg.rows > 0 && msg.rows <= 200) {
132
+ if (!client._terminalIds.has(msg.terminalId)) break;
93
133
  // #31: Relay resize errors back to client
94
134
  const resizeErr = resizeTerminal(msg.terminalId, msg.cols, msg.rows);
95
135
  if (resizeErr && client.readyState === 1) {
@@ -98,14 +138,14 @@ export function handleConnection(ws: WebSocket): void {
98
138
  }
99
139
  break;
100
140
  case WS_TYPES.TERMINAL_DISCONNECT:
101
- if (msg.terminalId) {
141
+ if (typeof msg.terminalId === 'string' && client._terminalIds.has(msg.terminalId)) {
102
142
  closeTerminal(msg.terminalId);
103
143
  client._terminalIds.delete(msg.terminalId);
104
144
  }
105
145
  break;
106
146
  case WS_TYPES.TERMINAL_SUBSCRIBE:
107
147
  // #30/#44: Only subscribe if terminal actually exists
108
- if (msg.terminalId) {
148
+ if (typeof msg.terminalId === 'string') {
109
149
  const exists = setWsClient(msg.terminalId, client);
110
150
  if (exists) {
111
151
  client._terminalIds.add(msg.terminalId);
@@ -115,7 +155,8 @@ export function handleConnection(ws: WebSocket): void {
115
155
  }
116
156
  break;
117
157
  case WS_TYPES.UPDATE_QUEUE_COUNT:
118
- if (msg.sessionId != null && msg.count != null) {
158
+ if (typeof msg.sessionId === 'string' && typeof msg.count === 'number'
159
+ && Number.isInteger(msg.count) && msg.count >= 0 && msg.count <= 10000) {
119
160
  const updated = updateQueueCount(msg.sessionId, msg.count);
120
161
  if (updated) {
121
162
  broadcast({ type: WS_TYPES.SESSION_UPDATE, session: updated });
@@ -124,7 +165,7 @@ export function handleConnection(ws: WebSocket): void {
124
165
  break;
125
166
  case WS_TYPES.REPLAY:
126
167
  // Client reconnected and wants events since a certain sequence number
127
- if (typeof msg.sinceSeq === 'number') {
168
+ if (typeof msg.sinceSeq === 'number' && msg.sinceSeq >= 0) {
128
169
  const missed = getEventsSince(msg.sinceSeq);
129
170
  log.debug('ws', `Replaying ${missed.length} events since seq=${msg.sinceSeq}`);
130
171
  for (const evt of missed) {
@@ -133,11 +174,11 @@ export function handleConnection(ws: WebSocket): void {
133
174
  }
134
175
  break;
135
176
  default:
136
- log.debug('ws', `Unknown message type: ${msg.type}`);
177
+ break; // Silently ignore unknown types
137
178
  }
138
179
  } catch (e: unknown) {
139
- const msg = e instanceof Error ? e.message : String(e);
140
- log.debug('ws', `Invalid WS message: ${msg}`);
180
+ const errMsg = e instanceof Error ? e.message : String(e);
181
+ log.debug('ws', `Invalid WS message: ${errMsg}`);
141
182
  }
142
183
  });
143
184