@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 +2 -2
- package/server/claude-sdk.js +37 -25
- package/server/database/db.js +47 -0
- package/server/index.js +165 -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.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.
|
|
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,14 @@ 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);
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
968
|
-
session.ws
|
|
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
|
-
|
|
974
|
-
|
|
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
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1209
|
-
session.ws
|
|
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
|
-
|
|
1215
|
-
|
|
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
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1435
|
-
session.ws
|
|
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
|
-
|
|
1441
|
-
|
|
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
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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) {
|
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 };
|