@ian2018cs/agenthub 0.1.26 → 0.1.28

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.28",
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,13 @@ 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);
630
682
  }
631
683
  }
632
684
 
@@ -733,6 +785,15 @@ function handleChatConnection(ws, userData) {
733
785
  cleanupUserProjectsWatcher(userUuid, ws);
734
786
  }
735
787
  });
788
+
789
+ ws.on('error', (error) => {
790
+ console.error('[ERROR] Chat WebSocket error:', error.message);
791
+ // Ensure cleanup on error as well (close may not fire after error)
792
+ connectedClients.delete(ws);
793
+ if (userUuid) {
794
+ cleanupUserProjectsWatcher(userUuid, ws);
795
+ }
796
+ });
736
797
  }
737
798
 
738
799
  // Handle shell WebSocket connections
@@ -786,18 +847,18 @@ function handleShellConnection(ws, userData) {
786
847
 
787
848
  clearTimeout(existingSession.timeoutId);
788
849
 
789
- ws.send(JSON.stringify({
850
+ safeSend(ws, {
790
851
  type: 'output',
791
852
  data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
792
- }));
853
+ });
793
854
 
794
855
  if (existingSession.buffer && existingSession.buffer.length > 0) {
795
856
  console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
796
857
  existingSession.buffer.forEach(bufferedData => {
797
- ws.send(JSON.stringify({
858
+ safeSend(ws, {
798
859
  type: 'output',
799
860
  data: bufferedData
800
- }));
861
+ });
801
862
  });
802
863
  }
803
864
 
@@ -899,6 +960,7 @@ function handleShellConnection(ws, userData) {
899
960
  pty: shellProcess,
900
961
  ws: ws,
901
962
  buffer: [],
963
+ bufferSize: 0,
902
964
  timeoutId: null,
903
965
  projectPath,
904
966
  sessionId
@@ -909,11 +971,17 @@ function handleShellConnection(ws, userData) {
909
971
  const session = ptySessionsMap.get(ptySessionKey);
910
972
  if (!session) return;
911
973
 
912
- if (session.buffer.length < 5000) {
974
+ // Byte-size limited buffer (MAX_BUFFER_SIZE)
975
+ if (session.bufferSize + data.length <= MAX_BUFFER_SIZE) {
913
976
  session.buffer.push(data);
977
+ session.bufferSize += data.length;
914
978
  } else {
915
- session.buffer.shift();
979
+ while (session.buffer.length > 0 && session.bufferSize + data.length > MAX_BUFFER_SIZE) {
980
+ const removed = session.buffer.shift();
981
+ session.bufferSize -= removed.length;
982
+ }
916
983
  session.buffer.push(data);
984
+ session.bufferSize += data.length;
917
985
  }
918
986
 
919
987
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
@@ -940,10 +1008,10 @@ function handleShellConnection(ws, userData) {
940
1008
  console.log('[DEBUG] Detected URL for opening:', url);
941
1009
 
942
1010
  // Send URL opening message to client
943
- session.ws.send(JSON.stringify({
1011
+ safeSend(session.ws, {
944
1012
  type: 'url_open',
945
1013
  url: url
946
- }));
1014
+ });
947
1015
 
948
1016
  // Replace the OPEN_URL pattern with a user-friendly message
949
1017
  if (pattern.source.includes('OPEN_URL')) {
@@ -953,10 +1021,10 @@ function handleShellConnection(ws, userData) {
953
1021
  });
954
1022
 
955
1023
  // Send regular output
956
- session.ws.send(JSON.stringify({
1024
+ safeSend(session.ws, {
957
1025
  type: 'output',
958
1026
  data: outputData
959
- }));
1027
+ });
960
1028
  }
961
1029
  });
962
1030
 
@@ -964,14 +1032,14 @@ function handleShellConnection(ws, userData) {
964
1032
  shellProcess.onExit((exitCode) => {
965
1033
  console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
966
1034
  const session = ptySessionsMap.get(ptySessionKey);
967
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
968
- session.ws.send(JSON.stringify({
1035
+ if (session) {
1036
+ safeSend(session.ws, {
969
1037
  type: 'output',
970
1038
  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);
1039
+ });
1040
+ if (session.timeoutId) {
1041
+ clearTimeout(session.timeoutId);
1042
+ }
975
1043
  }
976
1044
  ptySessionsMap.delete(ptySessionKey);
977
1045
  shellProcess = null;
@@ -979,10 +1047,10 @@ function handleShellConnection(ws, userData) {
979
1047
 
980
1048
  } catch (spawnError) {
981
1049
  console.error('[ERROR] Error spawning process:', spawnError);
982
- ws.send(JSON.stringify({
1050
+ safeSend(ws, {
983
1051
  type: 'output',
984
1052
  data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
985
- }));
1053
+ });
986
1054
  }
987
1055
 
988
1056
  } else if (data.type === 'input') {
@@ -1020,12 +1088,10 @@ function handleShellConnection(ws, userData) {
1020
1088
  }
1021
1089
  } catch (error) {
1022
1090
  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
- }
1091
+ safeSend(ws, {
1092
+ type: 'output',
1093
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1094
+ });
1029
1095
  }
1030
1096
  });
1031
1097
 
@@ -1038,6 +1104,10 @@ function handleShellConnection(ws, userData) {
1038
1104
  console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1039
1105
  session.ws = null;
1040
1106
 
1107
+ // Clear any existing timeout before setting a new one (prevent race condition)
1108
+ if (session.timeoutId) {
1109
+ clearTimeout(session.timeoutId);
1110
+ }
1041
1111
  session.timeoutId = setTimeout(() => {
1042
1112
  console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
1043
1113
  if (session.pty && session.pty.kill) {
@@ -1077,15 +1147,15 @@ function handleCodexConnection(ws, userData) {
1077
1147
  codexProcess = existingSession.pty;
1078
1148
  clearTimeout(existingSession.timeoutId);
1079
1149
 
1080
- ws.send(JSON.stringify({
1150
+ safeSend(ws, {
1081
1151
  type: 'output',
1082
1152
  data: `\x1b[36m[Reconnected to existing Codex session]\x1b[0m\r\n`
1083
- }));
1153
+ });
1084
1154
 
1085
1155
  if (existingSession.buffer && existingSession.buffer.length > 0) {
1086
1156
  console.log(`📜 Sending ${existingSession.buffer.length} buffered Codex messages`);
1087
1157
  existingSession.buffer.forEach(bufferedData => {
1088
- ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
1158
+ safeSend(ws, { type: 'output', data: bufferedData });
1089
1159
  });
1090
1160
  }
1091
1161
 
@@ -1095,10 +1165,10 @@ function handleCodexConnection(ws, userData) {
1095
1165
 
1096
1166
  console.log('[INFO] Starting Codex in:', projectPath);
1097
1167
 
1098
- ws.send(JSON.stringify({
1168
+ safeSend(ws, {
1099
1169
  type: 'output',
1100
1170
  data: `\x1b[36mStarting Codex in: ${projectPath}\x1b[0m\r\n`
1101
- }));
1171
+ });
1102
1172
 
1103
1173
  try {
1104
1174
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
@@ -1161,6 +1231,7 @@ function handleCodexConnection(ws, userData) {
1161
1231
  pty: codexProcess,
1162
1232
  ws: ws,
1163
1233
  buffer: [],
1234
+ bufferSize: 0,
1164
1235
  timeoutId: null,
1165
1236
  projectPath
1166
1237
  });
@@ -1169,11 +1240,17 @@ function handleCodexConnection(ws, userData) {
1169
1240
  const session = codexPtySessionsMap.get(ptySessionKey);
1170
1241
  if (!session) return;
1171
1242
 
1172
- if (session.buffer.length < 5000) {
1243
+ // Byte-size limited buffer (MAX_BUFFER_SIZE)
1244
+ if (session.bufferSize + rawData.length <= MAX_BUFFER_SIZE) {
1173
1245
  session.buffer.push(rawData);
1246
+ session.bufferSize += rawData.length;
1174
1247
  } else {
1175
- session.buffer.shift();
1248
+ while (session.buffer.length > 0 && session.bufferSize + rawData.length > MAX_BUFFER_SIZE) {
1249
+ const removed = session.buffer.shift();
1250
+ session.bufferSize -= removed.length;
1251
+ }
1176
1252
  session.buffer.push(rawData);
1253
+ session.bufferSize += rawData.length;
1177
1254
  }
1178
1255
 
1179
1256
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
@@ -1191,28 +1268,28 @@ function handleCodexConnection(ws, userData) {
1191
1268
  let match;
1192
1269
  while ((match = pattern.exec(rawData)) !== null) {
1193
1270
  const url = match[1];
1194
- session.ws.send(JSON.stringify({ type: 'url_open', url }));
1271
+ safeSend(session.ws, { type: 'url_open', url });
1195
1272
  if (pattern.source.includes('OPEN_URL')) {
1196
1273
  outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
1197
1274
  }
1198
1275
  }
1199
1276
  });
1200
1277
 
1201
- session.ws.send(JSON.stringify({ type: 'output', data: outputData }));
1278
+ safeSend(session.ws, { type: 'output', data: outputData });
1202
1279
  }
1203
1280
  });
1204
1281
 
1205
1282
  codexProcess.onExit((exitCode) => {
1206
1283
  console.log('🔚 Codex process exited with code:', exitCode.exitCode);
1207
1284
  const session = codexPtySessionsMap.get(ptySessionKey);
1208
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1209
- session.ws.send(JSON.stringify({
1285
+ if (session) {
1286
+ safeSend(session.ws, {
1210
1287
  type: 'output',
1211
1288
  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);
1289
+ });
1290
+ if (session.timeoutId) {
1291
+ clearTimeout(session.timeoutId);
1292
+ }
1216
1293
  }
1217
1294
  codexPtySessionsMap.delete(ptySessionKey);
1218
1295
  codexProcess = null;
@@ -1220,10 +1297,10 @@ function handleCodexConnection(ws, userData) {
1220
1297
 
1221
1298
  } catch (spawnError) {
1222
1299
  console.error('[ERROR] Error spawning Codex process:', spawnError);
1223
- ws.send(JSON.stringify({
1300
+ safeSend(ws, {
1224
1301
  type: 'output',
1225
1302
  data: `\r\n\x1b[31mError starting Codex: ${spawnError.message}\x1b[0m\r\n`
1226
- }));
1303
+ });
1227
1304
  }
1228
1305
 
1229
1306
  } else if (data.type === 'input') {
@@ -1250,12 +1327,10 @@ function handleCodexConnection(ws, userData) {
1250
1327
  }
1251
1328
  } catch (error) {
1252
1329
  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
- }
1330
+ safeSend(ws, {
1331
+ type: 'output',
1332
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1333
+ });
1259
1334
  }
1260
1335
  });
1261
1336
 
@@ -1266,6 +1341,10 @@ function handleCodexConnection(ws, userData) {
1266
1341
  if (session) {
1267
1342
  console.log('⏳ Codex PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1268
1343
  session.ws = null;
1344
+ // Clear any existing timeout before setting a new one (prevent race condition)
1345
+ if (session.timeoutId) {
1346
+ clearTimeout(session.timeoutId);
1347
+ }
1269
1348
  session.timeoutId = setTimeout(() => {
1270
1349
  console.log('⏰ Codex PTY session timeout, killing process:', ptySessionKey);
1271
1350
  if (session.pty && session.pty.kill) {
@@ -1304,15 +1383,15 @@ function handleGeminiConnection(ws, userData) {
1304
1383
  geminiProcess = existingSession.pty;
1305
1384
  clearTimeout(existingSession.timeoutId);
1306
1385
 
1307
- ws.send(JSON.stringify({
1386
+ safeSend(ws, {
1308
1387
  type: 'output',
1309
1388
  data: `\x1b[36m[Reconnected to existing Gemini session]\x1b[0m\r\n`
1310
- }));
1389
+ });
1311
1390
 
1312
1391
  if (existingSession.buffer && existingSession.buffer.length > 0) {
1313
1392
  console.log(`📜 Sending ${existingSession.buffer.length} buffered Gemini messages`);
1314
1393
  existingSession.buffer.forEach(bufferedData => {
1315
- ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
1394
+ safeSend(ws, { type: 'output', data: bufferedData });
1316
1395
  });
1317
1396
  }
1318
1397
 
@@ -1322,10 +1401,10 @@ function handleGeminiConnection(ws, userData) {
1322
1401
 
1323
1402
  console.log('[INFO] Starting Gemini in:', projectPath);
1324
1403
 
1325
- ws.send(JSON.stringify({
1404
+ safeSend(ws, {
1326
1405
  type: 'output',
1327
1406
  data: `\x1b[36mStarting Gemini in: ${projectPath}\x1b[0m\r\n`
1328
- }));
1407
+ });
1329
1408
 
1330
1409
  try {
1331
1410
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
@@ -1387,6 +1466,7 @@ function handleGeminiConnection(ws, userData) {
1387
1466
  pty: geminiProcess,
1388
1467
  ws: ws,
1389
1468
  buffer: [],
1469
+ bufferSize: 0,
1390
1470
  timeoutId: null,
1391
1471
  projectPath
1392
1472
  });
@@ -1395,11 +1475,17 @@ function handleGeminiConnection(ws, userData) {
1395
1475
  const session = geminiPtySessionsMap.get(ptySessionKey);
1396
1476
  if (!session) return;
1397
1477
 
1398
- if (session.buffer.length < 5000) {
1478
+ // Byte-size limited buffer (MAX_BUFFER_SIZE)
1479
+ if (session.bufferSize + rawData.length <= MAX_BUFFER_SIZE) {
1399
1480
  session.buffer.push(rawData);
1481
+ session.bufferSize += rawData.length;
1400
1482
  } else {
1401
- session.buffer.shift();
1483
+ while (session.buffer.length > 0 && session.bufferSize + rawData.length > MAX_BUFFER_SIZE) {
1484
+ const removed = session.buffer.shift();
1485
+ session.bufferSize -= removed.length;
1486
+ }
1402
1487
  session.buffer.push(rawData);
1488
+ session.bufferSize += rawData.length;
1403
1489
  }
1404
1490
 
1405
1491
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
@@ -1417,28 +1503,28 @@ function handleGeminiConnection(ws, userData) {
1417
1503
  let match;
1418
1504
  while ((match = pattern.exec(rawData)) !== null) {
1419
1505
  const url = match[1];
1420
- session.ws.send(JSON.stringify({ type: 'url_open', url }));
1506
+ safeSend(session.ws, { type: 'url_open', url });
1421
1507
  if (pattern.source.includes('OPEN_URL')) {
1422
1508
  outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
1423
1509
  }
1424
1510
  }
1425
1511
  });
1426
1512
 
1427
- session.ws.send(JSON.stringify({ type: 'output', data: outputData }));
1513
+ safeSend(session.ws, { type: 'output', data: outputData });
1428
1514
  }
1429
1515
  });
1430
1516
 
1431
1517
  geminiProcess.onExit((exitCode) => {
1432
1518
  console.log('🔚 Gemini process exited with code:', exitCode.exitCode);
1433
1519
  const session = geminiPtySessionsMap.get(ptySessionKey);
1434
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1435
- session.ws.send(JSON.stringify({
1520
+ if (session) {
1521
+ safeSend(session.ws, {
1436
1522
  type: 'output',
1437
1523
  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);
1524
+ });
1525
+ if (session.timeoutId) {
1526
+ clearTimeout(session.timeoutId);
1527
+ }
1442
1528
  }
1443
1529
  geminiPtySessionsMap.delete(ptySessionKey);
1444
1530
  geminiProcess = null;
@@ -1446,10 +1532,10 @@ function handleGeminiConnection(ws, userData) {
1446
1532
 
1447
1533
  } catch (spawnError) {
1448
1534
  console.error('[ERROR] Error spawning Gemini process:', spawnError);
1449
- ws.send(JSON.stringify({
1535
+ safeSend(ws, {
1450
1536
  type: 'output',
1451
1537
  data: `\r\n\x1b[31mError starting Gemini: ${spawnError.message}\x1b[0m\r\n`
1452
- }));
1538
+ });
1453
1539
  }
1454
1540
 
1455
1541
  } else if (data.type === 'input') {
@@ -1476,12 +1562,10 @@ function handleGeminiConnection(ws, userData) {
1476
1562
  }
1477
1563
  } catch (error) {
1478
1564
  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
- }
1565
+ safeSend(ws, {
1566
+ type: 'output',
1567
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1568
+ });
1485
1569
  }
1486
1570
  });
1487
1571
 
@@ -1492,6 +1576,10 @@ function handleGeminiConnection(ws, userData) {
1492
1576
  if (session) {
1493
1577
  console.log('⏳ Gemini PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1494
1578
  session.ws = null;
1579
+ // Clear any existing timeout before setting a new one (prevent race condition)
1580
+ if (session.timeoutId) {
1581
+ clearTimeout(session.timeoutId);
1582
+ }
1495
1583
  session.timeoutId = setTimeout(() => {
1496
1584
  console.log('⏰ Gemini PTY session timeout, killing process:', ptySessionKey);
1497
1585
  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 };