@ian2018cs/agenthub 0.1.26 → 0.1.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian2018cs/agenthub",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "A web-based UI for AI Agents",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -44,7 +44,7 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.2.50",
47
+ "@anthropic-ai/claude-agent-sdk": "^0.2.61",
48
48
  "@codemirror/lang-css": "^6.3.1",
49
49
  "@codemirror/lang-html": "^6.4.9",
50
50
  "@codemirror/lang-javascript": "^6.2.4",
@@ -263,6 +263,18 @@ function getAllSessions() {
263
263
  return Array.from(activeSessions.keys());
264
264
  }
265
265
 
266
+ // Periodic cleanup of stale sessions (every 5 minutes, remove sessions older than 2 hours)
267
+ const SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1000;
268
+ setInterval(() => {
269
+ const now = Date.now();
270
+ for (const [sessionId, session] of activeSessions) {
271
+ if (now - session.startTime > SESSION_MAX_AGE_MS) {
272
+ console.log(`[WARN] Cleaning up stale active session: ${sessionId} (age: ${Math.round((now - session.startTime) / 60000)}min)`);
273
+ activeSessions.delete(sessionId);
274
+ }
275
+ }
276
+ }, 5 * 60 * 1000);
277
+
266
278
  /**
267
279
  * Transforms SDK messages to WebSocket format expected by frontend
268
280
  * @param {Object} sdkMessage - SDK message object
@@ -662,32 +674,32 @@ async function queryClaudeSDK(command, options = {}, ws) {
662
674
  cacheCreationTokens: hasPreciseCacheData ? undefined : cacheCreationTokens
663
675
  });
664
676
 
665
- // Insert usage record
666
- usageDb.insertRecord({
667
- user_uuid: userUuid,
668
- session_id: capturedSessionId,
669
- model: normalizedModel,
670
- raw_model: modelKey,
671
- input_tokens: inputTokens,
672
- output_tokens: outputTokens,
673
- cache_read_tokens: cacheReadTokens,
674
- cache_creation_tokens: cacheCreationTokens,
675
- cost_usd: cost,
676
- source: 'sdk'
677
- });
678
-
679
- // Update daily summary
677
+ // Atomically insert usage record + update daily summary in a single transaction
680
678
  const today = new Date().toISOString().split('T')[0];
681
- usageDb.upsertDailySummary({
682
- user_uuid: userUuid,
683
- date: today,
684
- model: normalizedModel,
685
- total_input_tokens: inputTokens,
686
- total_output_tokens: outputTokens,
687
- total_cost_usd: cost,
688
- session_count: 0, // Session count updated separately
689
- request_count: 1
690
- });
679
+ usageDb.recordUsageTransaction(
680
+ {
681
+ user_uuid: userUuid,
682
+ session_id: capturedSessionId,
683
+ model: normalizedModel,
684
+ raw_model: modelKey,
685
+ input_tokens: inputTokens,
686
+ output_tokens: outputTokens,
687
+ cache_read_tokens: cacheReadTokens,
688
+ cache_creation_tokens: cacheCreationTokens,
689
+ cost_usd: cost,
690
+ source: 'sdk'
691
+ },
692
+ {
693
+ user_uuid: userUuid,
694
+ date: today,
695
+ model: normalizedModel,
696
+ total_input_tokens: inputTokens,
697
+ total_output_tokens: outputTokens,
698
+ total_cost_usd: cost,
699
+ session_count: 0, // Session count updated separately
700
+ request_count: 1
701
+ }
702
+ );
691
703
 
692
704
  console.log(`Recorded usage for user ${userUuid}: ${normalizedModel}, cost: $${cost.toFixed(6)}`);
693
705
  }
@@ -45,6 +45,11 @@ try {
45
45
  // Create database connection
46
46
  const db = new Database(DB_PATH);
47
47
 
48
+ // Optimize SQLite for concurrent access
49
+ db.pragma('journal_mode = WAL'); // Write-Ahead Logging for concurrent reads during writes
50
+ db.pragma('busy_timeout = 5000'); // Wait up to 5s when database is locked instead of failing immediately
51
+ db.pragma('synchronous = NORMAL'); // Balance between safety and performance with WAL
52
+
48
53
  // Show app installation path prominently
49
54
  const appInstallPath = path.join(__dirname, '../..');
50
55
  console.log('');
@@ -675,6 +680,48 @@ const usageDb = {
675
680
  }
676
681
  },
677
682
 
683
+ // Atomic transaction: insert usage record + upsert daily summary
684
+ recordUsageTransaction: db.transaction((record, summary) => {
685
+ const insertStmt = db.prepare(`
686
+ INSERT INTO usage_records (user_uuid, session_id, model, raw_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, source, created_at)
687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
688
+ `);
689
+ insertStmt.run(
690
+ record.user_uuid,
691
+ record.session_id || null,
692
+ record.model,
693
+ record.raw_model || null,
694
+ record.input_tokens || 0,
695
+ record.output_tokens || 0,
696
+ record.cache_read_tokens || 0,
697
+ record.cache_creation_tokens || 0,
698
+ record.cost_usd || 0,
699
+ record.source || 'sdk',
700
+ record.created_at || new Date().toISOString()
701
+ );
702
+
703
+ const upsertStmt = db.prepare(`
704
+ INSERT INTO usage_daily_summary (user_uuid, date, model, total_input_tokens, total_output_tokens, total_cost_usd, session_count, request_count)
705
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
706
+ ON CONFLICT(user_uuid, date, model) DO UPDATE SET
707
+ total_input_tokens = total_input_tokens + excluded.total_input_tokens,
708
+ total_output_tokens = total_output_tokens + excluded.total_output_tokens,
709
+ total_cost_usd = total_cost_usd + excluded.total_cost_usd,
710
+ session_count = session_count + excluded.session_count,
711
+ request_count = request_count + excluded.request_count
712
+ `);
713
+ upsertStmt.run(
714
+ summary.user_uuid,
715
+ summary.date,
716
+ summary.model,
717
+ summary.total_input_tokens || 0,
718
+ summary.total_output_tokens || 0,
719
+ summary.total_cost_usd || 0,
720
+ summary.session_count || 0,
721
+ summary.request_count || 0
722
+ );
723
+ }),
724
+
678
725
  // Get all users usage summary
679
726
  getAllUsersSummary: () => {
680
727
  try {
package/server/index.js CHANGED
@@ -131,9 +131,7 @@ async function setupUserProjectsWatcher(userUuid, ws) {
131
131
  const userWatcher = userWatchers.get(userUuid);
132
132
  if (userWatcher) {
133
133
  userWatcher.clients.forEach(client => {
134
- if (client.readyState === WebSocket.OPEN) {
135
- client.send(updateMessage);
136
- }
134
+ safeSend(client, updateMessage);
137
135
  });
138
136
  }
139
137
  } catch (error) {
@@ -178,6 +176,23 @@ function cleanupUserProjectsWatcher(userUuid, ws) {
178
176
  }
179
177
  }
180
178
 
179
+ // Periodic cleanup of dead connections from userWatchers (every 5 minutes)
180
+ const WATCHER_CLEANUP_INTERVAL = 5 * 60 * 1000;
181
+ setInterval(() => {
182
+ for (const [userUuid, { watcher, clients }] of userWatchers) {
183
+ for (const client of clients) {
184
+ if (client.readyState !== 1) { // Not WebSocket.OPEN
185
+ clients.delete(client);
186
+ }
187
+ }
188
+ if (clients.size === 0) {
189
+ console.log(`[INFO] Cleaning up orphaned watcher for user ${userUuid}`);
190
+ watcher.close();
191
+ userWatchers.delete(userUuid);
192
+ }
193
+ }
194
+ }, WATCHER_CLEANUP_INTERVAL);
195
+
181
196
 
182
197
  const app = express();
183
198
  const server = http.createServer(app);
@@ -186,6 +201,18 @@ const ptySessionsMap = new Map();
186
201
  const codexPtySessionsMap = new Map();
187
202
  const geminiPtySessionsMap = new Map();
188
203
  const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
204
+ const MAX_BUFFER_SIZE = 5 * 1024 * 1024; // 5MB per PTY session buffer
205
+
206
+ // Safe WebSocket send - prevents crashes when connection is already closed
207
+ function safeSend(ws, data) {
208
+ try {
209
+ if (ws && ws.readyState === WebSocket.OPEN) {
210
+ ws.send(typeof data === 'string' ? data : JSON.stringify(data));
211
+ }
212
+ } catch (err) {
213
+ console.error('[WARN] WebSocket send failed:', err.message);
214
+ }
215
+ }
189
216
 
190
217
  // Single WebSocket server that handles both paths
191
218
  const wss = new WebSocketServer({
@@ -228,6 +255,23 @@ const wss = new WebSocketServer({
228
255
  // Make WebSocket server available to routes
229
256
  app.locals.wss = wss;
230
257
 
258
+ // WebSocket heartbeat to detect and clean up dead connections
259
+ const HEARTBEAT_INTERVAL = 30000; // 30 seconds
260
+ const heartbeatInterval = setInterval(() => {
261
+ wss.clients.forEach(ws => {
262
+ if (ws.isAlive === false) {
263
+ console.log('[INFO] Terminating dead WebSocket connection');
264
+ return ws.terminate();
265
+ }
266
+ ws.isAlive = false;
267
+ ws.ping();
268
+ });
269
+ }, HEARTBEAT_INTERVAL);
270
+
271
+ wss.on('close', () => {
272
+ clearInterval(heartbeatInterval);
273
+ });
274
+
231
275
  app.use(cors());
232
276
  app.use(express.json({
233
277
  limit: '50mb',
@@ -592,6 +636,10 @@ wss.on('connection', (ws, request) => {
592
636
  const url = request.url;
593
637
  console.log('[INFO] Client connected to:', url);
594
638
 
639
+ // Initialize heartbeat tracking
640
+ ws.isAlive = true;
641
+ ws.on('pong', () => { ws.isAlive = true; });
642
+
595
643
  // Get user data from WebSocket authentication
596
644
  const userData = request.user;
597
645
 
@@ -624,9 +672,14 @@ class WebSocketWriter {
624
672
  }
625
673
 
626
674
  send(data) {
627
- if (this.ws.readyState === 1) { // WebSocket.OPEN
628
- // Providers send raw objects, we stringify for WebSocket
629
- this.ws.send(JSON.stringify(data));
675
+ try {
676
+ if (this.ws.readyState === 1) { // WebSocket.OPEN
677
+ // Providers send raw objects, we stringify for WebSocket
678
+ this.ws.send(JSON.stringify(data));
679
+ }
680
+ } catch (err) {
681
+ console.error('[WARN] WebSocketWriter send failed:', err.message);
682
+ }
630
683
  }
631
684
  }
632
685
 
@@ -733,6 +786,15 @@ function handleChatConnection(ws, userData) {
733
786
  cleanupUserProjectsWatcher(userUuid, ws);
734
787
  }
735
788
  });
789
+
790
+ ws.on('error', (error) => {
791
+ console.error('[ERROR] Chat WebSocket error:', error.message);
792
+ // Ensure cleanup on error as well (close may not fire after error)
793
+ connectedClients.delete(ws);
794
+ if (userUuid) {
795
+ cleanupUserProjectsWatcher(userUuid, ws);
796
+ }
797
+ });
736
798
  }
737
799
 
738
800
  // Handle shell WebSocket connections
@@ -786,18 +848,18 @@ function handleShellConnection(ws, userData) {
786
848
 
787
849
  clearTimeout(existingSession.timeoutId);
788
850
 
789
- ws.send(JSON.stringify({
851
+ safeSend(ws, {
790
852
  type: 'output',
791
853
  data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
792
- }));
854
+ });
793
855
 
794
856
  if (existingSession.buffer && existingSession.buffer.length > 0) {
795
857
  console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
796
858
  existingSession.buffer.forEach(bufferedData => {
797
- ws.send(JSON.stringify({
859
+ safeSend(ws, {
798
860
  type: 'output',
799
861
  data: bufferedData
800
- }));
862
+ });
801
863
  });
802
864
  }
803
865
 
@@ -899,6 +961,7 @@ function handleShellConnection(ws, userData) {
899
961
  pty: shellProcess,
900
962
  ws: ws,
901
963
  buffer: [],
964
+ bufferSize: 0,
902
965
  timeoutId: null,
903
966
  projectPath,
904
967
  sessionId
@@ -909,11 +972,17 @@ function handleShellConnection(ws, userData) {
909
972
  const session = ptySessionsMap.get(ptySessionKey);
910
973
  if (!session) return;
911
974
 
912
- if (session.buffer.length < 5000) {
975
+ // Byte-size limited buffer (MAX_BUFFER_SIZE)
976
+ if (session.bufferSize + data.length <= MAX_BUFFER_SIZE) {
913
977
  session.buffer.push(data);
978
+ session.bufferSize += data.length;
914
979
  } else {
915
- session.buffer.shift();
980
+ while (session.buffer.length > 0 && session.bufferSize + data.length > MAX_BUFFER_SIZE) {
981
+ const removed = session.buffer.shift();
982
+ session.bufferSize -= removed.length;
983
+ }
916
984
  session.buffer.push(data);
985
+ session.bufferSize += data.length;
917
986
  }
918
987
 
919
988
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
@@ -940,10 +1009,10 @@ function handleShellConnection(ws, userData) {
940
1009
  console.log('[DEBUG] Detected URL for opening:', url);
941
1010
 
942
1011
  // Send URL opening message to client
943
- session.ws.send(JSON.stringify({
1012
+ safeSend(session.ws, {
944
1013
  type: 'url_open',
945
1014
  url: url
946
- }));
1015
+ });
947
1016
 
948
1017
  // Replace the OPEN_URL pattern with a user-friendly message
949
1018
  if (pattern.source.includes('OPEN_URL')) {
@@ -953,10 +1022,10 @@ function handleShellConnection(ws, userData) {
953
1022
  });
954
1023
 
955
1024
  // Send regular output
956
- session.ws.send(JSON.stringify({
1025
+ safeSend(session.ws, {
957
1026
  type: 'output',
958
1027
  data: outputData
959
- }));
1028
+ });
960
1029
  }
961
1030
  });
962
1031
 
@@ -964,14 +1033,14 @@ function handleShellConnection(ws, userData) {
964
1033
  shellProcess.onExit((exitCode) => {
965
1034
  console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
966
1035
  const session = ptySessionsMap.get(ptySessionKey);
967
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
968
- session.ws.send(JSON.stringify({
1036
+ if (session) {
1037
+ safeSend(session.ws, {
969
1038
  type: 'output',
970
1039
  data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
971
- }));
972
- }
973
- if (session && session.timeoutId) {
974
- clearTimeout(session.timeoutId);
1040
+ });
1041
+ if (session.timeoutId) {
1042
+ clearTimeout(session.timeoutId);
1043
+ }
975
1044
  }
976
1045
  ptySessionsMap.delete(ptySessionKey);
977
1046
  shellProcess = null;
@@ -979,10 +1048,10 @@ function handleShellConnection(ws, userData) {
979
1048
 
980
1049
  } catch (spawnError) {
981
1050
  console.error('[ERROR] Error spawning process:', spawnError);
982
- ws.send(JSON.stringify({
1051
+ safeSend(ws, {
983
1052
  type: 'output',
984
1053
  data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
985
- }));
1054
+ });
986
1055
  }
987
1056
 
988
1057
  } else if (data.type === 'input') {
@@ -1020,12 +1089,10 @@ function handleShellConnection(ws, userData) {
1020
1089
  }
1021
1090
  } catch (error) {
1022
1091
  console.error('[ERROR] Shell WebSocket error:', error.message);
1023
- if (ws.readyState === WebSocket.OPEN) {
1024
- ws.send(JSON.stringify({
1025
- type: 'output',
1026
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1027
- }));
1028
- }
1092
+ safeSend(ws, {
1093
+ type: 'output',
1094
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1095
+ });
1029
1096
  }
1030
1097
  });
1031
1098
 
@@ -1038,6 +1105,10 @@ function handleShellConnection(ws, userData) {
1038
1105
  console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1039
1106
  session.ws = null;
1040
1107
 
1108
+ // Clear any existing timeout before setting a new one (prevent race condition)
1109
+ if (session.timeoutId) {
1110
+ clearTimeout(session.timeoutId);
1111
+ }
1041
1112
  session.timeoutId = setTimeout(() => {
1042
1113
  console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
1043
1114
  if (session.pty && session.pty.kill) {
@@ -1077,15 +1148,15 @@ function handleCodexConnection(ws, userData) {
1077
1148
  codexProcess = existingSession.pty;
1078
1149
  clearTimeout(existingSession.timeoutId);
1079
1150
 
1080
- ws.send(JSON.stringify({
1151
+ safeSend(ws, {
1081
1152
  type: 'output',
1082
1153
  data: `\x1b[36m[Reconnected to existing Codex session]\x1b[0m\r\n`
1083
- }));
1154
+ });
1084
1155
 
1085
1156
  if (existingSession.buffer && existingSession.buffer.length > 0) {
1086
1157
  console.log(`📜 Sending ${existingSession.buffer.length} buffered Codex messages`);
1087
1158
  existingSession.buffer.forEach(bufferedData => {
1088
- ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
1159
+ safeSend(ws, { type: 'output', data: bufferedData });
1089
1160
  });
1090
1161
  }
1091
1162
 
@@ -1095,10 +1166,10 @@ function handleCodexConnection(ws, userData) {
1095
1166
 
1096
1167
  console.log('[INFO] Starting Codex in:', projectPath);
1097
1168
 
1098
- ws.send(JSON.stringify({
1169
+ safeSend(ws, {
1099
1170
  type: 'output',
1100
1171
  data: `\x1b[36mStarting Codex in: ${projectPath}\x1b[0m\r\n`
1101
- }));
1172
+ });
1102
1173
 
1103
1174
  try {
1104
1175
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
@@ -1161,6 +1232,7 @@ function handleCodexConnection(ws, userData) {
1161
1232
  pty: codexProcess,
1162
1233
  ws: ws,
1163
1234
  buffer: [],
1235
+ bufferSize: 0,
1164
1236
  timeoutId: null,
1165
1237
  projectPath
1166
1238
  });
@@ -1169,11 +1241,17 @@ function handleCodexConnection(ws, userData) {
1169
1241
  const session = codexPtySessionsMap.get(ptySessionKey);
1170
1242
  if (!session) return;
1171
1243
 
1172
- if (session.buffer.length < 5000) {
1244
+ // Byte-size limited buffer (MAX_BUFFER_SIZE)
1245
+ if (session.bufferSize + rawData.length <= MAX_BUFFER_SIZE) {
1173
1246
  session.buffer.push(rawData);
1247
+ session.bufferSize += rawData.length;
1174
1248
  } else {
1175
- session.buffer.shift();
1249
+ while (session.buffer.length > 0 && session.bufferSize + rawData.length > MAX_BUFFER_SIZE) {
1250
+ const removed = session.buffer.shift();
1251
+ session.bufferSize -= removed.length;
1252
+ }
1176
1253
  session.buffer.push(rawData);
1254
+ session.bufferSize += rawData.length;
1177
1255
  }
1178
1256
 
1179
1257
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
@@ -1191,28 +1269,28 @@ function handleCodexConnection(ws, userData) {
1191
1269
  let match;
1192
1270
  while ((match = pattern.exec(rawData)) !== null) {
1193
1271
  const url = match[1];
1194
- session.ws.send(JSON.stringify({ type: 'url_open', url }));
1272
+ safeSend(session.ws, { type: 'url_open', url });
1195
1273
  if (pattern.source.includes('OPEN_URL')) {
1196
1274
  outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
1197
1275
  }
1198
1276
  }
1199
1277
  });
1200
1278
 
1201
- session.ws.send(JSON.stringify({ type: 'output', data: outputData }));
1279
+ safeSend(session.ws, { type: 'output', data: outputData });
1202
1280
  }
1203
1281
  });
1204
1282
 
1205
1283
  codexProcess.onExit((exitCode) => {
1206
1284
  console.log('🔚 Codex process exited with code:', exitCode.exitCode);
1207
1285
  const session = codexPtySessionsMap.get(ptySessionKey);
1208
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1209
- session.ws.send(JSON.stringify({
1286
+ if (session) {
1287
+ safeSend(session.ws, {
1210
1288
  type: 'output',
1211
1289
  data: `\r\n\x1b[33mCodex exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
1212
- }));
1213
- }
1214
- if (session && session.timeoutId) {
1215
- clearTimeout(session.timeoutId);
1290
+ });
1291
+ if (session.timeoutId) {
1292
+ clearTimeout(session.timeoutId);
1293
+ }
1216
1294
  }
1217
1295
  codexPtySessionsMap.delete(ptySessionKey);
1218
1296
  codexProcess = null;
@@ -1220,10 +1298,10 @@ function handleCodexConnection(ws, userData) {
1220
1298
 
1221
1299
  } catch (spawnError) {
1222
1300
  console.error('[ERROR] Error spawning Codex process:', spawnError);
1223
- ws.send(JSON.stringify({
1301
+ safeSend(ws, {
1224
1302
  type: 'output',
1225
1303
  data: `\r\n\x1b[31mError starting Codex: ${spawnError.message}\x1b[0m\r\n`
1226
- }));
1304
+ });
1227
1305
  }
1228
1306
 
1229
1307
  } else if (data.type === 'input') {
@@ -1250,12 +1328,10 @@ function handleCodexConnection(ws, userData) {
1250
1328
  }
1251
1329
  } catch (error) {
1252
1330
  console.error('[ERROR] Codex WebSocket error:', error.message);
1253
- if (ws.readyState === WebSocket.OPEN) {
1254
- ws.send(JSON.stringify({
1255
- type: 'output',
1256
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1257
- }));
1258
- }
1331
+ safeSend(ws, {
1332
+ type: 'output',
1333
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1334
+ });
1259
1335
  }
1260
1336
  });
1261
1337
 
@@ -1266,6 +1342,10 @@ function handleCodexConnection(ws, userData) {
1266
1342
  if (session) {
1267
1343
  console.log('⏳ Codex PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1268
1344
  session.ws = null;
1345
+ // Clear any existing timeout before setting a new one (prevent race condition)
1346
+ if (session.timeoutId) {
1347
+ clearTimeout(session.timeoutId);
1348
+ }
1269
1349
  session.timeoutId = setTimeout(() => {
1270
1350
  console.log('⏰ Codex PTY session timeout, killing process:', ptySessionKey);
1271
1351
  if (session.pty && session.pty.kill) {
@@ -1304,15 +1384,15 @@ function handleGeminiConnection(ws, userData) {
1304
1384
  geminiProcess = existingSession.pty;
1305
1385
  clearTimeout(existingSession.timeoutId);
1306
1386
 
1307
- ws.send(JSON.stringify({
1387
+ safeSend(ws, {
1308
1388
  type: 'output',
1309
1389
  data: `\x1b[36m[Reconnected to existing Gemini session]\x1b[0m\r\n`
1310
- }));
1390
+ });
1311
1391
 
1312
1392
  if (existingSession.buffer && existingSession.buffer.length > 0) {
1313
1393
  console.log(`📜 Sending ${existingSession.buffer.length} buffered Gemini messages`);
1314
1394
  existingSession.buffer.forEach(bufferedData => {
1315
- ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
1395
+ safeSend(ws, { type: 'output', data: bufferedData });
1316
1396
  });
1317
1397
  }
1318
1398
 
@@ -1322,10 +1402,10 @@ function handleGeminiConnection(ws, userData) {
1322
1402
 
1323
1403
  console.log('[INFO] Starting Gemini in:', projectPath);
1324
1404
 
1325
- ws.send(JSON.stringify({
1405
+ safeSend(ws, {
1326
1406
  type: 'output',
1327
1407
  data: `\x1b[36mStarting Gemini in: ${projectPath}\x1b[0m\r\n`
1328
- }));
1408
+ });
1329
1409
 
1330
1410
  try {
1331
1411
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
@@ -1387,6 +1467,7 @@ function handleGeminiConnection(ws, userData) {
1387
1467
  pty: geminiProcess,
1388
1468
  ws: ws,
1389
1469
  buffer: [],
1470
+ bufferSize: 0,
1390
1471
  timeoutId: null,
1391
1472
  projectPath
1392
1473
  });
@@ -1395,11 +1476,17 @@ function handleGeminiConnection(ws, userData) {
1395
1476
  const session = geminiPtySessionsMap.get(ptySessionKey);
1396
1477
  if (!session) return;
1397
1478
 
1398
- if (session.buffer.length < 5000) {
1479
+ // Byte-size limited buffer (MAX_BUFFER_SIZE)
1480
+ if (session.bufferSize + rawData.length <= MAX_BUFFER_SIZE) {
1399
1481
  session.buffer.push(rawData);
1482
+ session.bufferSize += rawData.length;
1400
1483
  } else {
1401
- session.buffer.shift();
1484
+ while (session.buffer.length > 0 && session.bufferSize + rawData.length > MAX_BUFFER_SIZE) {
1485
+ const removed = session.buffer.shift();
1486
+ session.bufferSize -= removed.length;
1487
+ }
1402
1488
  session.buffer.push(rawData);
1489
+ session.bufferSize += rawData.length;
1403
1490
  }
1404
1491
 
1405
1492
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
@@ -1417,28 +1504,28 @@ function handleGeminiConnection(ws, userData) {
1417
1504
  let match;
1418
1505
  while ((match = pattern.exec(rawData)) !== null) {
1419
1506
  const url = match[1];
1420
- session.ws.send(JSON.stringify({ type: 'url_open', url }));
1507
+ safeSend(session.ws, { type: 'url_open', url });
1421
1508
  if (pattern.source.includes('OPEN_URL')) {
1422
1509
  outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
1423
1510
  }
1424
1511
  }
1425
1512
  });
1426
1513
 
1427
- session.ws.send(JSON.stringify({ type: 'output', data: outputData }));
1514
+ safeSend(session.ws, { type: 'output', data: outputData });
1428
1515
  }
1429
1516
  });
1430
1517
 
1431
1518
  geminiProcess.onExit((exitCode) => {
1432
1519
  console.log('🔚 Gemini process exited with code:', exitCode.exitCode);
1433
1520
  const session = geminiPtySessionsMap.get(ptySessionKey);
1434
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1435
- session.ws.send(JSON.stringify({
1521
+ if (session) {
1522
+ safeSend(session.ws, {
1436
1523
  type: 'output',
1437
1524
  data: `\r\n\x1b[33mGemini exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
1438
- }));
1439
- }
1440
- if (session && session.timeoutId) {
1441
- clearTimeout(session.timeoutId);
1525
+ });
1526
+ if (session.timeoutId) {
1527
+ clearTimeout(session.timeoutId);
1528
+ }
1442
1529
  }
1443
1530
  geminiPtySessionsMap.delete(ptySessionKey);
1444
1531
  geminiProcess = null;
@@ -1446,10 +1533,10 @@ function handleGeminiConnection(ws, userData) {
1446
1533
 
1447
1534
  } catch (spawnError) {
1448
1535
  console.error('[ERROR] Error spawning Gemini process:', spawnError);
1449
- ws.send(JSON.stringify({
1536
+ safeSend(ws, {
1450
1537
  type: 'output',
1451
1538
  data: `\r\n\x1b[31mError starting Gemini: ${spawnError.message}\x1b[0m\r\n`
1452
- }));
1539
+ });
1453
1540
  }
1454
1541
 
1455
1542
  } else if (data.type === 'input') {
@@ -1476,12 +1563,10 @@ function handleGeminiConnection(ws, userData) {
1476
1563
  }
1477
1564
  } catch (error) {
1478
1565
  console.error('[ERROR] Gemini WebSocket error:', error.message);
1479
- if (ws.readyState === WebSocket.OPEN) {
1480
- ws.send(JSON.stringify({
1481
- type: 'output',
1482
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1483
- }));
1484
- }
1566
+ safeSend(ws, {
1567
+ type: 'output',
1568
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1569
+ });
1485
1570
  }
1486
1571
  });
1487
1572
 
@@ -1492,6 +1577,10 @@ function handleGeminiConnection(ws, userData) {
1492
1577
  if (session) {
1493
1578
  console.log('⏳ Gemini PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1494
1579
  session.ws = null;
1580
+ // Clear any existing timeout before setting a new one (prevent race condition)
1581
+ if (session.timeoutId) {
1582
+ clearTimeout(session.timeoutId);
1583
+ }
1495
1584
  session.timeoutId = setTimeout(() => {
1496
1585
  console.log('⏰ Gemini PTY session timeout, killing process:', ptySessionKey);
1497
1586
  if (session.pty && session.pty.kill) {
@@ -29,31 +29,24 @@ const upload = multer({
29
29
  });
30
30
 
31
31
  /**
32
- * Parse skill metadata from SKILLS.md file
32
+ * Parse skill metadata from SKILL.md YAML frontmatter
33
33
  */
34
34
  async function parseSkillMetadata(skillPath) {
35
35
  try {
36
36
  const skillsFile = path.join(skillPath, 'SKILL.md');
37
37
  const content = await fs.readFile(skillsFile, 'utf-8');
38
38
 
39
- // Extract title from first # heading
40
- const titleMatch = content.match(/^#\s+(.+)$/m);
41
- const title = titleMatch ? titleMatch[1].trim() : path.basename(skillPath);
42
-
43
- // Extract description from content after title (first paragraph)
44
- const lines = content.split('\n');
39
+ let title = path.basename(skillPath);
45
40
  let description = '';
46
- let foundTitle = false;
47
- for (const line of lines) {
48
- if (line.startsWith('#')) {
49
- if (foundTitle) break;
50
- foundTitle = true;
51
- continue;
52
- }
53
- if (foundTitle && line.trim()) {
54
- description = line.trim();
55
- break;
56
- }
41
+
42
+ // Parse YAML frontmatter (between --- delimiters)
43
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
44
+ if (fmMatch) {
45
+ const fm = fmMatch[1];
46
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
47
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
48
+ if (nameMatch) title = nameMatch[1].trim();
49
+ if (descMatch) description = descMatch[1].trim();
57
50
  }
58
51
 
59
52
  return { title, description };