@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 +2 -2
- package/server/claude-sdk.js +37 -25
- package/server/database/db.js +47 -0
- package/server/index.js +164 -76
- package/server/routes/skills.js +11 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian2018cs/agenthub",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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",
|
package/server/claude-sdk.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
}
|
package/server/database/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
968
|
-
session.ws
|
|
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
|
-
|
|
974
|
-
|
|
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
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1209
|
-
session.ws
|
|
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
|
-
|
|
1215
|
-
|
|
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
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1435
|
-
session.ws
|
|
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
|
-
|
|
1441
|
-
|
|
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
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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) {
|
package/server/routes/skills.js
CHANGED
|
@@ -29,31 +29,24 @@ const upload = multer({
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Parse skill metadata from
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
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 };
|