agentgui 1.0.837 → 1.0.839

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/server.js CHANGED
@@ -27,6 +27,12 @@ import { register as registerThreadRoutes } from './lib/routes-threads.js';
27
27
  import { register as registerDebugRoutes } from './lib/routes-debug.js';
28
28
  import { register as registerConvRoutes } from './lib/routes-conversations.js';
29
29
  import { register as registerAgentRoutes } from './lib/routes-agents.js';
30
+ import { register as registerMessagesRoutes } from './lib/routes-messages.js';
31
+ import { register as registerSessionsRoutes } from './lib/routes-sessions.js';
32
+ import { register as registerRunsRoutes } from './lib/routes-runs.js';
33
+ import { register as registerScriptsRoutes } from './lib/routes-scripts.js';
34
+ import { register as registerAgentActionsRoutes } from './lib/routes-agent-actions.js';
35
+ import { register as registerAuthConfigRoutes } from './lib/routes-auth-config.js';
30
36
  import { startCodexOAuth, exchangeCodexOAuthCode, handleCodexOAuthCallback, getCodexOAuthStatus, getCodexOAuthState, CODEX_HOME, CODEX_AUTH_FILE } from './lib/oauth-codex.js';
31
37
  import { WSOptimizer } from './lib/ws-optimizer.js';
32
38
  import { WsRouter } from './lib/ws-protocol.js';
@@ -53,6 +59,10 @@ import CheckpointManager from './lib/checkpoint-manager.js';
53
59
  import { JsonlWatcher } from './lib/jsonl-watcher.js';
54
60
  import { createBroadcast } from './lib/broadcast.js';
55
61
  import { createRecovery } from './lib/recovery.js';
62
+ import { parseRateLimitResetTime } from './lib/process-message-rate-limit.js';
63
+ import { createEventHandler } from './lib/stream-event-handler.js';
64
+ import { createMessageQueue } from './lib/message-queue.js';
65
+ import { createProcessMessage } from './lib/process-message.js';
56
66
 
57
67
 
58
68
  process.on('uncaughtException', (err, origin) => {
@@ -546,1049 +556,29 @@ const server = http.createServer(async (req, res) => {
546
556
  const convHandler = _convRoutes._match(req.method, pathOnly);
547
557
  if (convHandler) { await convHandler(req, res); return; }
548
558
 
549
- const messagesMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/messages$/);
550
- if (messagesMatch) {
551
- if (req.method === 'GET') {
552
- const url = new URL(req.url, 'http://localhost');
553
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 500);
554
- const offset = Math.max(parseInt(url.searchParams.get('offset') || '0'), 0);
555
- const result = queries.getPaginatedMessages(messagesMatch[1], limit, offset);
556
- sendJSON(req, res, 200, result);
557
- return;
558
- }
559
-
560
- if (req.method === 'POST') {
561
- const conversationId = messagesMatch[1];
562
- const conv = queries.getConversation(conversationId);
563
- if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
564
- const body = await parseBody(req);
565
- const agentId = body.agentId || conv.agentType || conv.agentId || 'claude-code';
566
- const model = body.model || conv.model || null;
567
- const subAgent = body.subAgent || conv.subAgent || null;
568
- const idempotencyKey = body.idempotencyKey || null;
569
- const message = queries.createMessage(conversationId, 'user', body.content, idempotencyKey);
570
- queries.createEvent('message.created', { role: 'user', messageId: message.id }, conversationId);
571
- broadcastSync({ type: 'message_created', conversationId, message, timestamp: Date.now() });
572
-
573
- if (activeExecutions.has(conversationId)) {
574
- if (!messageQueues.has(conversationId)) messageQueues.set(conversationId, []);
575
- messageQueues.get(conversationId).push({ content: body.content, agentId, model, messageId: message.id, subAgent });
576
- const queueLength = messageQueues.get(conversationId).length;
577
- broadcastSync({ type: 'queue_status', conversationId, queueLength, messageId: message.id, timestamp: Date.now() });
578
- sendJSON(req, res, 200, { message, queued: true, queuePosition: queueLength, idempotencyKey });
579
- return;
580
- }
581
-
582
- const session = queries.createSession(conversationId);
583
- queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, conversationId, session.id);
584
-
585
- activeExecutions.set(conversationId, { pid: null, startTime: Date.now(), sessionId: session.id, lastActivity: Date.now() });
586
- queries.setIsStreaming(conversationId, true);
587
-
588
- broadcastSync({
589
- type: 'streaming_start',
590
- sessionId: session.id,
591
- conversationId,
592
- messageId: message.id,
593
- agentId,
594
- timestamp: Date.now()
595
- });
596
-
597
- sendJSON(req, res, 201, { message, session, idempotencyKey });
598
-
599
- processMessageWithStreaming(conversationId, message.id, session.id, body.content, agentId, model, subAgent)
600
- .catch(err => {
601
- console.error(`[messages] Uncaught error for conv ${conversationId}:`, err.message);
602
- debugLog(`[messages] Uncaught error: ${err.message}`);
603
- logError('processMessageWithStreaming', err, { convId: conversationId });
604
- });
605
- return;
606
- }
607
- }
608
-
609
- const streamMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/stream$/);
610
- if (streamMatch && req.method === 'POST') {
611
- const conversationId = streamMatch[1];
612
- const body = await parseBody(req);
613
- const conv = queries.getConversation(conversationId);
614
- if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
615
-
616
- const prompt = body.content || body.message || '';
617
- const agentId = body.agentId || conv.agentType || conv.agentId || 'claude-code';
618
- const model = body.model || conv.model || null;
619
- const subAgent = body.subAgent || conv.subAgent || null;
620
-
621
- const userMessage = queries.createMessage(conversationId, 'user', prompt);
622
- queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, conversationId);
623
-
624
- broadcastSync({ type: 'message_created', conversationId, message: userMessage, timestamp: Date.now() });
625
-
626
- if (activeExecutions.has(conversationId)) {
627
- debugLog(`[stream] Conversation ${conversationId} is busy, queuing message`);
628
- if (!messageQueues.has(conversationId)) messageQueues.set(conversationId, []);
629
- messageQueues.get(conversationId).push({ content: prompt, agentId, model, messageId: userMessage.id, subAgent });
630
-
631
- const queueLength = messageQueues.get(conversationId).length;
632
- broadcastSync({ type: 'queue_status', conversationId, queueLength, messageId: userMessage.id, timestamp: Date.now() });
633
-
634
- sendJSON(req, res, 200, { message: userMessage, queued: true, queuePosition: queueLength });
635
- return;
636
- }
637
-
638
- const session = queries.createSession(conversationId);
639
- queries.createEvent('session.created', { messageId: userMessage.id, sessionId: session.id }, conversationId, session.id);
640
-
641
- activeExecutions.set(conversationId, { pid: null, startTime: Date.now(), sessionId: session.id, lastActivity: Date.now() });
642
- queries.setIsStreaming(conversationId, true);
643
-
644
- broadcastSync({
645
- type: 'streaming_start',
646
- sessionId: session.id,
647
- conversationId,
648
- messageId: userMessage.id,
649
- agentId,
650
- timestamp: Date.now()
651
- });
652
-
653
- sendJSON(req, res, 200, { message: userMessage, session, streamId: session.id });
654
-
655
- processMessageWithStreaming(conversationId, userMessage.id, session.id, prompt, agentId, model, subAgent)
656
- .catch(err => debugLog(`[stream] Uncaught error: ${err.stack || err.message}`));
657
- return;
658
- }
659
-
660
- const queueMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/queue$/);
661
- if (queueMatch && req.method === 'GET') {
662
- const conversationId = queueMatch[1];
663
- const conv = queries.getConversation(conversationId);
664
- if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
665
- const queue = messageQueues.get(conversationId) || [];
666
- sendJSON(req, res, 200, { queue });
667
- return;
668
- }
669
-
670
- const queueItemMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/queue\/([^/]+)$/);
671
- if (queueItemMatch && req.method === 'DELETE') {
672
- const conversationId = queueItemMatch[1];
673
- const messageId = queueItemMatch[2];
674
- const queue = messageQueues.get(conversationId);
675
- if (!queue) { sendJSON(req, res, 404, { error: 'Queue not found' }); return; }
676
- const index = queue.findIndex(q => q.messageId === messageId);
677
- if (index === -1) { sendJSON(req, res, 404, { error: 'Queued message not found' }); return; }
678
- queue.splice(index, 1);
679
- if (queue.length === 0) messageQueues.delete(conversationId);
680
- broadcastSync({ type: 'queue_status', conversationId, queueLength: queue?.length || 0, timestamp: Date.now() });
681
- sendJSON(req, res, 200, { deleted: true });
682
- return;
683
- }
684
-
685
- if (queueItemMatch && req.method === 'PATCH') {
686
- const conversationId = queueItemMatch[1];
687
- const messageId = queueItemMatch[2];
688
- const body = await parseBody(req);
689
- const queue = messageQueues.get(conversationId);
690
- if (!queue) { sendJSON(req, res, 404, { error: 'Queue not found' }); return; }
691
- const item = queue.find(q => q.messageId === messageId);
692
- if (!item) { sendJSON(req, res, 404, { error: 'Queued message not found' }); return; }
693
- if (body.content !== undefined) item.content = body.content;
694
- if (body.agentId !== undefined) item.agentId = body.agentId;
695
- broadcastSync({ type: 'queue_updated', conversationId, messageId, content: item.content, agentId: item.agentId, timestamp: Date.now() });
696
- sendJSON(req, res, 200, { updated: true, item });
697
- return;
698
- }
699
-
700
- const messageMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/messages\/([^/]+)$/);
701
- if (messageMatch && req.method === 'GET') {
702
- const msg = queries.getMessage(messageMatch[2]);
703
- if (!msg || msg.conversationId !== messageMatch[1]) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
704
- sendJSON(req, res, 200, { message: msg });
705
- return;
706
- }
707
-
708
- const sessionMatch = pathOnly.match(/^\/api\/sessions\/([^/]+)$/);
709
- if (sessionMatch && req.method === 'GET') {
710
- const sess = queries.getSession(sessionMatch[1]);
711
- if (!sess) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
712
- const events = queries.getSessionEvents(sessionMatch[1]);
713
- sendJSON(req, res, 200, { session: sess, events });
714
- return;
715
- }
716
-
717
- const fullLoadMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/full$/);
718
- if (fullLoadMatch && req.method === 'GET') {
719
- const conversationId = fullLoadMatch[1];
720
- const conv = queries.getConversation(conversationId);
721
- if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
722
- const latestSession = queries.getLatestSession(conversationId);
723
- const isActivelyStreaming = activeExecutions.has(conversationId);
724
-
725
- const url = new URL(req.url, 'http://localhost');
726
- const chunkLimit = Math.min(parseInt(url.searchParams.get('chunkLimit') || '500'), 5000);
727
- const allChunks = url.searchParams.get('allChunks') === '1';
728
-
729
- const totalChunks = queries.getConversationChunkCount(conversationId);
730
- let chunks;
731
- if (allChunks || totalChunks <= chunkLimit) {
732
- chunks = queries.getConversationChunks(conversationId);
733
- } else {
734
- chunks = queries.getRecentConversationChunks(conversationId, chunkLimit);
735
- }
736
- const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
737
- const rateLimitInfo = rateLimitState.get(conversationId) || null;
738
- sendJSON(req, res, 200, {
739
- conversation: conv,
740
- isActivelyStreaming,
741
- latestSession,
742
- chunks,
743
- totalChunks,
744
- messages: msgResult.messages,
745
- rateLimitInfo
746
- });
747
- return;
748
- }
749
-
750
- const conversationChunksMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/chunks$/);
751
- if (conversationChunksMatch && req.method === 'GET') {
752
- const conversationId = conversationChunksMatch[1];
753
- const conv = queries.getConversation(conversationId);
754
- if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
755
-
756
- const url = new URL(req.url, 'http://localhost');
757
- const since = parseInt(url.searchParams.get('since') || '0');
758
- const all = url.searchParams.get('all') === 'true';
759
- const totalChunks = queries.getConversationChunkCount(conversationId);
760
- let chunks;
761
- if (since > 0) {
762
- chunks = queries.getConversationChunksSince(conversationId, since);
763
- } else if (all) {
764
- chunks = queries.getConversationChunks(conversationId);
765
- } else {
766
- chunks = queries.getRecentConversationChunks(conversationId, 500);
767
- }
768
- debugLog(`[chunks] Conv ${conversationId}: ${chunks.length} chunks (total: ${totalChunks})`);
769
- sendJSON(req, res, 200, { ok: true, chunks, totalChunks });
770
- return;
771
- }
772
-
773
- const sessionChunksMatch = pathOnly.match(/^\/api\/sessions\/([^/]+)\/chunks$/);
774
- if (sessionChunksMatch && req.method === 'GET') {
775
- const sessionId = sessionChunksMatch[1];
776
- const sess = queries.getSession(sessionId);
777
- if (!sess) { sendJSON(req, res, 404, { error: 'Session not found' }); return; }
778
-
779
- const url = new URL(req.url, 'http://localhost');
780
- const sinceSeq = parseInt(url.searchParams.get('sinceSeq') || '-1');
781
- const since = parseInt(url.searchParams.get('since') || '0');
782
-
783
- let chunks;
784
- if (sinceSeq >= 0) {
785
- chunks = queries.getChunksSinceSeq(sessionId, sinceSeq);
786
- } else {
787
- chunks = queries.getChunksSince(sessionId, since);
788
- }
789
- sendJSON(req, res, 200, { ok: true, chunks });
790
- return;
791
- }
792
-
793
- if (pathOnly.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/) && req.method === 'GET') {
794
- const convId = pathOnly.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/)[1];
795
- const latestSession = queries.getLatestSession(convId);
796
- if (!latestSession) {
797
- sendJSON(req, res, 200, { session: null });
798
- return;
799
- }
800
- const events = queries.getSessionEvents(latestSession.id);
801
- sendJSON(req, res, 200, { session: latestSession, events });
802
- return;
803
- }
804
-
805
- const executionMatch = pathOnly.match(/^\/api\/sessions\/([^/]+)\/execution$/);
806
- if (executionMatch && req.method === 'GET') {
807
- const sessionId = executionMatch[1];
808
- const url = new URL(req.url, 'http://localhost');
809
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '1000'), 5000);
810
- const offset = Math.max(parseInt(url.searchParams.get('offset') || '0'), 0);
811
- const filterType = url.searchParams.get('filterType');
812
-
813
- try {
814
- const session = queries.getSession(sessionId);
815
- const allChunks = session ? (queries.getChunksSince(sessionId, 0) || []) : [];
816
- const filtered = filterType ? allChunks.filter(e => e.type === filterType) : allChunks;
817
- const executionData = {
818
- sessionId,
819
- events: filtered.slice(offset, offset + limit),
820
- total: filtered.length,
821
- limit,
822
- offset,
823
- hasMore: offset + limit < filtered.length,
824
- metadata: {
825
- status: session?.status || 'unknown',
826
- startTime: session?.created_at || null,
827
- duration: session?.completed_at && session?.created_at ? session.completed_at - session.created_at : 0,
828
- eventCount: filtered.length
829
- }
830
- };
831
-
832
- sendJSON(req, res, 200, executionData);
833
- } catch (err) {
834
- sendJSON(req, res, 400, { error: err.message });
835
- }
836
- return;
837
- }
838
-
839
- const runsMatch = pathOnly.match(/^\/api\/runs$/);
840
- if (runsMatch && req.method === 'POST') {
841
- let body = '';
842
- for await (const chunk of req) { body += chunk; }
843
- let parsed = {};
844
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
845
-
846
- const { input, agentId, webhook } = parsed;
847
- if (!input) {
848
- sendJSON(req, res, 400, { error: 'Missing input in request body' });
849
- return;
850
- }
851
-
852
- const resolvedAgentId = agentId || 'claude-code';
853
- const resolvedModel = parsed.model || null;
854
- const cwd = parsed.workingDirectory || STARTUP_CWD;
855
-
856
- const thread = queries.createConversation(resolvedAgentId, 'Stateless Run', cwd);
857
- const session = queries.createSession(thread.id, resolvedAgentId, 'pending');
858
- const message = queries.createMessage(thread.id, 'user', typeof input === 'string' ? input : JSON.stringify(input));
859
-
860
- processMessageWithStreaming(thread.id, message.id, session.id, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
861
-
862
- sendJSON(req, res, 200, {
863
- id: session.id,
864
- status: 'pending',
865
- started_at: session.started_at,
866
- agentId: resolvedAgentId
867
- });
868
- return;
869
- }
870
-
871
- const runsSearchMatch = pathOnly.match(/^\/api\/runs\/search$/);
872
- if (runsSearchMatch && req.method === 'POST') {
873
- const sessions = queries.getAllSessions();
874
- const runs = sessions.slice(0, 50).map(s => ({
875
- id: s.id,
876
- status: s.status,
877
- started_at: s.started_at,
878
- completed_at: s.completed_at,
879
- agentId: s.agentId,
880
- input: null,
881
- output: null
882
- })).reverse();
883
- sendJSON(req, res, 200, runs);
884
- return;
885
- }
886
-
887
- // POST /runs/stream - SSE removed, use WebSocket
888
- if (pathOnly === '/api/runs/stream' && req.method === 'POST') {
889
- res.writeHead(410); res.end(JSON.stringify({ error: 'SSE removed, use WebSocket' }));
890
- return;
891
- }
892
-
893
- const scriptsMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/scripts$/);
894
- if (scriptsMatch && req.method === 'GET') {
895
- const conv = queries.getConversation(scriptsMatch[1]);
896
- if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
897
- const wd = conv.workingDirectory || STARTUP_CWD;
898
- let hasStart = false, hasDev = false;
899
- try {
900
- const pkg = JSON.parse(fs.readFileSync(path.join(wd, 'package.json'), 'utf-8'));
901
- const scripts = pkg.scripts || {};
902
- hasStart = !!scripts.start;
903
- hasDev = !!scripts.dev;
904
- } catch {}
905
- const running = activeScripts.has(scriptsMatch[1]);
906
- const runningScript = running ? activeScripts.get(scriptsMatch[1]).script : null;
907
- sendJSON(req, res, 200, { hasStart, hasDev, running, runningScript });
908
- return;
909
- }
910
-
911
- const runScriptMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/run-script$/);
912
- if (runScriptMatch && req.method === 'POST') {
913
- const conversationId = runScriptMatch[1];
914
- const conv = queries.getConversation(conversationId);
915
- if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
916
- if (activeScripts.has(conversationId)) { sendJSON(req, res, 409, { error: 'Script already running' }); return; }
917
- const body = await parseBody(req);
918
- const script = body.script;
919
- if (script !== 'start' && script !== 'dev') { sendJSON(req, res, 400, { error: 'Invalid script' }); return; }
920
- const wd = conv.workingDirectory || STARTUP_CWD;
921
- try {
922
- const pkg = JSON.parse(fs.readFileSync(path.join(wd, 'package.json'), 'utf-8'));
923
- if (!pkg.scripts || !pkg.scripts[script]) { sendJSON(req, res, 400, { error: `Script "${script}" not found` }); return; }
924
- } catch { sendJSON(req, res, 400, { error: 'No package.json' }); return; }
925
-
926
- const childEnv = { ...process.env, FORCE_COLOR: '1' };
927
- delete childEnv.PORT;
928
- delete childEnv.BASE_URL;
929
- delete childEnv.HOT_RELOAD;
930
- const isWindows = os.platform() === 'win32';
931
- const child = spawn('npm', ['run', script], { cwd: wd, stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: childEnv, shell: isWindows });
932
- activeScripts.set(conversationId, { process: child, script, startTime: Date.now() });
933
- broadcastSync({ type: 'script_started', conversationId, script, timestamp: Date.now() });
934
-
935
- const onData = (stream) => (chunk) => {
936
- broadcastSync({ type: 'script_output', conversationId, data: chunk.toString(), stream, timestamp: Date.now() });
937
- };
938
- child.stdout.on('data', onData('stdout'));
939
- child.stderr.on('data', onData('stderr'));
940
- child.stdout.on('error', () => {});
941
- child.stderr.on('error', () => {});
942
- child.on('error', (err) => {
943
- activeScripts.delete(conversationId);
944
- broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: err.message, timestamp: Date.now() });
945
- });
946
- child.on('close', (code) => {
947
- activeScripts.delete(conversationId);
948
- broadcastSync({ type: 'script_stopped', conversationId, code: code || 0, timestamp: Date.now() });
949
- });
950
- sendJSON(req, res, 200, { ok: true, script, pid: child.pid });
951
- return;
952
- }
953
-
954
- const stopScriptMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/stop-script$/);
955
- if (stopScriptMatch && req.method === 'POST') {
956
- const conversationId = stopScriptMatch[1];
957
- const entry = activeScripts.get(conversationId);
958
- if (!entry) { sendJSON(req, res, 404, { error: 'No running script' }); return; }
959
- try { process.kill(-entry.process.pid, 'SIGTERM'); } catch { try { entry.process.kill('SIGTERM'); } catch {} }
960
- sendJSON(req, res, 200, { ok: true });
961
- return;
962
- }
963
-
964
- const scriptStatusMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/script-status$/);
965
- if (scriptStatusMatch && req.method === 'GET') {
966
- const entry = activeScripts.get(scriptStatusMatch[1]);
967
- sendJSON(req, res, 200, { running: !!entry, script: entry?.script || null });
968
- return;
969
- }
970
-
971
- const cancelRunMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/cancel$/);
972
- if (cancelRunMatch && req.method === 'POST') {
973
- const conversationId = cancelRunMatch[1];
974
- const entry = activeExecutions.get(conversationId);
975
-
976
- if (!entry) {
977
- sendJSON(req, res, 404, { error: 'No active execution to cancel' });
978
- return;
979
- }
980
-
981
- const { pid, sessionId } = entry;
982
-
983
- if (pid) {
984
- try {
985
- process.kill(-pid, 'SIGKILL');
986
- } catch {
987
- try {
988
- process.kill(pid, 'SIGKILL');
989
- } catch (e) {}
990
- }
991
- }
992
-
993
- if (sessionId) {
994
- queries.updateSession(sessionId, {
995
- status: 'interrupted',
996
- completed_at: Date.now()
997
- });
998
- }
999
-
1000
- queries.setIsStreaming(conversationId, false);
1001
- activeExecutions.delete(conversationId);
1002
-
1003
- broadcastSync({
1004
- type: 'streaming_complete',
1005
- sessionId,
1006
- conversationId,
1007
- interrupted: true,
1008
- timestamp: Date.now()
1009
- });
559
+ const messagesHandler = _messagesRoutes._match(req.method, pathOnly);
560
+ if (messagesHandler) { await messagesHandler(req, res); return; }
1010
561
 
1011
- sendJSON(req, res, 200, { ok: true, cancelled: true, conversationId, sessionId });
1012
- return;
1013
- }
562
+ const sessionsHandler = _sessionsRoutes._match(req.method, pathOnly);
563
+ if (sessionsHandler) { await sessionsHandler(req, res); return; }
1014
564
 
1015
- const resumeRunMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/resume$/);
1016
- if (resumeRunMatch && req.method === 'POST') {
1017
- const conversationId = resumeRunMatch[1];
1018
- const conv = queries.getConversation(conversationId);
1019
-
1020
- if (!conv) {
1021
- sendJSON(req, res, 404, { error: 'Conversation not found' });
1022
- return;
1023
- }
565
+ const scriptsHandler = _scriptsRoutes._match(req.method, pathOnly);
566
+ if (scriptsHandler) { await scriptsHandler(req, res); return; }
1024
567
 
1025
- const activeEntry = activeExecutions.get(conversationId);
1026
- if (activeEntry) {
1027
- sendJSON(req, res, 409, { error: 'Conversation already has an active execution' });
1028
- return;
1029
- }
1030
-
1031
- let body = '';
1032
- for await (const chunk of req) { body += chunk; }
1033
- let parsed = {};
1034
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
1035
-
1036
- const { content, agentId } = parsed;
1037
- if (!content) {
1038
- sendJSON(req, res, 400, { error: 'Missing content in request body' });
1039
- return;
1040
- }
1041
-
1042
- const resolvedAgentId = agentId || conv.agentId || 'claude-code';
1043
- const resolvedModel = parsed.model || conv.model || null;
1044
- const cwd = conv.workingDirectory || STARTUP_CWD;
1045
-
1046
- const session = queries.createSession(conversationId, resolvedAgentId, 'pending');
1047
-
1048
- const message = queries.createMessage(conversationId, 'user', content);
1049
-
1050
- processMessageWithStreaming(conversationId, message.id, session.id, content, resolvedAgentId, resolvedModel);
1051
-
1052
- sendJSON(req, res, 200, {
1053
- ok: true,
1054
- conversationId,
1055
- sessionId: session.id,
1056
- messageId: message.id,
1057
- resumed: true
1058
- });
1059
- return;
1060
- }
1061
-
1062
- const injectMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/inject$/);
1063
- if (injectMatch && req.method === 'POST') {
1064
- const conversationId = injectMatch[1];
1065
- const conv = queries.getConversation(conversationId);
1066
-
1067
- if (!conv) {
1068
- sendJSON(req, res, 404, { error: 'Conversation not found' });
1069
- return;
1070
- }
1071
-
1072
- let body = '';
1073
- for await (const chunk of req) { body += chunk; }
1074
- let parsed = {};
1075
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
1076
-
1077
- const { content, eager } = parsed;
1078
- if (!content) {
1079
- sendJSON(req, res, 400, { error: 'Missing content in request body' });
1080
- return;
1081
- }
1082
-
1083
- const entry = activeExecutions.get(conversationId);
1084
-
1085
- if (entry && eager) {
1086
- sendJSON(req, res, 409, { error: 'Cannot eagerly inject while execution is running - message queued' });
1087
- return;
1088
- }
1089
-
1090
- const message = queries.createMessage(conversationId, 'user', '[INJECTED] ' + content);
1091
-
1092
- if (!entry) {
1093
- const resolvedAgentId = conv.agentId || 'claude-code';
1094
- const resolvedModel = conv.model || null;
1095
- const cwd = conv.workingDirectory || STARTUP_CWD;
1096
- const session = queries.createSession(conversationId, resolvedAgentId, 'pending');
1097
- processMessageWithStreaming(conversationId, message.id, session.id, message.content, resolvedAgentId, resolvedModel);
1098
- }
1099
-
1100
- sendJSON(req, res, 200, { ok: true, injected: true, conversationId, messageId: message.id });
1101
- return;
1102
- }
568
+ const runsHandlerA = _runsRoutes._match(req.method, pathOnly);
569
+ if (runsHandlerA) { await runsHandlerA(req, res); return; }
1103
570
 
1104
571
  const agentHandler = _agentRoutes._match(req.method, pathOnly);
1105
572
  if (agentHandler) { await agentHandler(req, res); return; }
1106
573
 
1107
- if (pathOnly === '/api/runs' && req.method === 'POST') {
1108
- const body = await parseBody(req);
1109
- const { agent_id, input, config, webhook_url } = body;
1110
- if (!agent_id) {
1111
- sendJSON(req, res, 422, { error: 'agent_id is required' });
1112
- return;
1113
- }
1114
- const agent = discoveredAgents.find(a => a.id === agent_id);
1115
- if (!agent) {
1116
- sendJSON(req, res, 404, { error: 'Agent not found' });
1117
- return;
1118
- }
1119
- const run = queries.createRun(agent_id, null, input, config, webhook_url);
1120
- sendJSON(req, res, 201, run);
1121
- return;
1122
- }
1123
-
1124
- if (pathOnly === '/api/runs/search' && req.method === 'POST') {
1125
- const body = await parseBody(req);
1126
- const result = queries.searchRuns(body);
1127
- sendJSON(req, res, 200, result);
1128
- return;
1129
- }
1130
-
1131
- if (pathOnly === '/api/runs/wait' && req.method === 'POST') {
1132
- const body = await parseBody(req);
1133
- const { agent_id, input, config } = body;
1134
- if (!agent_id) {
1135
- sendJSON(req, res, 422, { error: 'agent_id is required' });
1136
- return;
1137
- }
1138
- const agent = discoveredAgents.find(a => a.id === agent_id);
1139
- if (!agent) {
1140
- sendJSON(req, res, 404, { error: 'Agent not found' });
1141
- return;
1142
- }
1143
- const run = queries.createRun(agent_id, null, input, config);
1144
- const statelessThreadId = queries.getRun(run.run_id)?.thread_id;
1145
- if (statelessThreadId && input?.content) {
1146
- try {
1147
- await runClaudeWithStreaming(agent_id, statelessThreadId, input.content, config?.model || null);
1148
- const finalRun = queries.getRun(run.run_id);
1149
- sendJSON(req, res, 200, finalRun);
1150
- } catch (err) {
1151
- queries.updateRunStatus(run.run_id, 'error');
1152
- sendJSON(req, res, 500, { error: err.message });
1153
- }
1154
- } else {
1155
- sendJSON(req, res, 200, run);
1156
- }
1157
- return;
1158
- }
1159
-
1160
- const oldRunByIdMatch1 = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1161
- if (oldRunByIdMatch1) {
1162
- const runId = oldRunByIdMatch1[1];
1163
-
1164
- if (req.method === 'GET') {
1165
- const run = queries.getRun(runId);
1166
- if (!run) {
1167
- sendJSON(req, res, 404, { error: 'Run not found' });
1168
- return;
1169
- }
1170
- sendJSON(req, res, 200, run);
1171
- return;
1172
- }
1173
-
1174
- if (req.method === 'POST') {
1175
- const body = await parseBody(req);
1176
- const run = queries.getRun(runId);
1177
- if (!run) {
1178
- sendJSON(req, res, 404, { error: 'Run not found' });
1179
- return;
1180
- }
1181
- if (run.status !== 'pending') {
1182
- sendJSON(req, res, 409, { error: 'Run is not resumable' });
1183
- return;
1184
- }
1185
- if (body.input?.content && run.thread_id) {
1186
- runClaudeWithStreaming(run.agent_id, run.thread_id, body.input.content, null).catch(() => {});
1187
- }
1188
- sendJSON(req, res, 200, run);
1189
- return;
1190
- }
1191
-
1192
- if (req.method === 'DELETE') {
1193
- try {
1194
- queries.deleteRun(runId);
1195
- res.writeHead(204);
1196
- res.end();
1197
- } catch (err) {
1198
- sendJSON(req, res, 404, { error: 'Run not found' });
1199
- }
1200
- return;
1201
- }
1202
- }
1203
-
1204
- const runWaitMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/wait$/);
1205
- if (runWaitMatch && req.method === 'GET') {
1206
- const runId = runWaitMatch[1];
1207
- const run = queries.getRun(runId);
1208
- if (!run) {
1209
- sendJSON(req, res, 404, { error: 'Run not found' });
1210
- return;
1211
- }
1212
- const startTime = Date.now();
1213
- const pollInterval = setInterval(() => {
1214
- const currentRun = queries.getRun(runId);
1215
- const elapsed = Date.now() - startTime;
1216
- const done = currentRun && ['success', 'error', 'cancelled'].includes(currentRun.status);
1217
- if (done) {
1218
- clearInterval(pollInterval);
1219
- sendJSON(req, res, 200, currentRun);
1220
- } else if (elapsed > 30000) {
1221
- clearInterval(pollInterval);
1222
- sendJSON(req, res, 408, { error: 'Run still pending after 30s', run_id: runId, status: currentRun?.status || run.status });
1223
- }
1224
- }, 500);
1225
- req.on('close', () => clearInterval(pollInterval));
1226
- return;
1227
- }
1228
-
1229
- const runStreamMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/stream$/);
1230
- if (runStreamMatch && req.method === 'GET') {
1231
- res.writeHead(410); res.end(JSON.stringify({ error: 'SSE removed, use WebSocket' }));
1232
- return;
1233
- }
1234
-
1235
- const oldRunCancelMatch1 = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
1236
- if (oldRunCancelMatch1 && req.method === 'POST') {
1237
- const runId = oldRunCancelMatch1[1];
1238
- try {
1239
- const run = queries.getRun(runId);
1240
- if (!run) {
1241
- sendJSON(req, res, 404, { error: 'Run not found' });
1242
- return;
1243
- }
1244
-
1245
- if (['success', 'error', 'cancelled'].includes(run.status)) {
1246
- sendJSON(req, res, 409, { error: 'Run already completed or cancelled' });
1247
- return;
1248
- }
1249
-
1250
- const cancelledRun = queries.cancelRun(runId);
1251
-
1252
- const threadId = run.thread_id;
1253
- if (threadId) {
1254
- const execution = activeExecutions.get(threadId);
1255
- if (execution?.pid) {
1256
- try {
1257
- process.kill(-execution.pid, 'SIGTERM');
1258
- } catch {
1259
- try {
1260
- process.kill(execution.pid, 'SIGTERM');
1261
- } catch (e) {
1262
- console.error(`[cancel] Failed to SIGTERM PID ${execution.pid}:`, e.message);
1263
- }
1264
- }
1265
-
1266
- setTimeout(() => {
1267
- try {
1268
- process.kill(-execution.pid, 'SIGKILL');
1269
- } catch {
1270
- try {
1271
- process.kill(execution.pid, 'SIGKILL');
1272
- } catch (e) {}
1273
- }
1274
- }, 3000);
1275
- }
1276
-
1277
- if (execution?.sessionId) {
1278
- queries.updateSession(execution.sessionId, {
1279
- status: 'error',
1280
- error: 'Cancelled by user',
1281
- completed_at: Date.now()
1282
- });
1283
- }
1284
-
1285
- activeExecutions.delete(threadId);
1286
- queries.setIsStreaming(threadId, false);
1287
-
1288
- broadcastSync({
1289
- type: 'streaming_cancelled',
1290
- sessionId: execution?.sessionId || runId,
1291
- conversationId: threadId,
1292
- runId: runId,
1293
- timestamp: Date.now()
1294
- });
1295
- }
1296
-
1297
- sendJSON(req, res, 200, cancelledRun);
1298
- } catch (err) {
1299
- if (err.message === 'Run not found') {
1300
- sendJSON(req, res, 404, { error: err.message });
1301
- } else if (err.message.includes('already completed')) {
1302
- sendJSON(req, res, 409, { error: err.message });
1303
- } else {
1304
- sendJSON(req, res, 500, { error: err.message });
1305
- }
1306
- }
1307
- return;
1308
- }
1309
-
1310
- const threadRunCancelMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/cancel$/);
1311
- if (threadRunCancelMatch && req.method === 'POST') {
1312
- const threadId = threadRunCancelMatch[1];
1313
- const runId = threadRunCancelMatch[2];
1314
-
1315
- try {
1316
- const run = queries.getRun(runId);
1317
- if (!run) {
1318
- sendJSON(req, res, 404, { error: 'Run not found' });
1319
- return;
1320
- }
1321
-
1322
- if (run.thread_id !== threadId) {
1323
- sendJSON(req, res, 400, { error: 'Run does not belong to specified thread' });
1324
- return;
1325
- }
1326
-
1327
- if (['success', 'error', 'cancelled'].includes(run.status)) {
1328
- sendJSON(req, res, 409, { error: 'Run already completed or cancelled' });
1329
- return;
1330
- }
1331
-
1332
- const cancelledRun = queries.cancelRun(runId);
1333
-
1334
- const execution = activeExecutions.get(threadId);
1335
- if (execution?.pid) {
1336
- try {
1337
- process.kill(-execution.pid, 'SIGTERM');
1338
- } catch {
1339
- try {
1340
- process.kill(execution.pid, 'SIGTERM');
1341
- } catch (e) {
1342
- console.error(`[cancel] Failed to SIGTERM PID ${execution.pid}:`, e.message);
1343
- }
1344
- }
1345
-
1346
- setTimeout(() => {
1347
- try {
1348
- process.kill(-execution.pid, 'SIGKILL');
1349
- } catch {
1350
- try {
1351
- process.kill(execution.pid, 'SIGKILL');
1352
- } catch (e) {}
1353
- }
1354
- }, 3000);
1355
- }
1356
-
1357
- if (execution?.sessionId) {
1358
- queries.updateSession(execution.sessionId, {
1359
- status: 'error',
1360
- error: 'Cancelled by user',
1361
- completed_at: Date.now()
1362
- });
1363
- }
1364
-
1365
- activeExecutions.delete(threadId);
1366
- activeProcessesByRunId.delete(runId);
1367
- queries.setIsStreaming(threadId, false);
1368
-
1369
- broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: execution?.sessionId, timestamp: Date.now() });
1370
- broadcastSync({ type: 'streaming_cancelled', sessionId: execution?.sessionId || runId, conversationId: threadId, runId, timestamp: Date.now() });
1371
-
1372
- sendJSON(req, res, 200, cancelledRun);
1373
- } catch (err) {
1374
- if (err.message === 'Run not found') {
1375
- sendJSON(req, res, 404, { error: err.message });
1376
- } else if (err.message.includes('already completed')) {
1377
- sendJSON(req, res, 409, { error: err.message });
1378
- } else {
1379
- sendJSON(req, res, 500, { error: err.message });
1380
- }
1381
- }
1382
- return;
1383
- }
1384
-
1385
- const threadRunWaitMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/wait$/);
1386
- if (threadRunWaitMatch && req.method === 'GET') {
1387
- const threadId = threadRunWaitMatch[1];
1388
- const runId = threadRunWaitMatch[2];
1389
-
1390
- const run = queries.getRun(runId);
1391
- if (!run) {
1392
- sendJSON(req, res, 404, { error: 'Run not found' });
1393
- return;
1394
- }
1395
-
1396
- if (run.thread_id !== threadId) {
1397
- sendJSON(req, res, 400, { error: 'Run does not belong to specified thread' });
1398
- return;
1399
- }
1400
-
1401
- const startTime = Date.now();
1402
- const pollInterval = setInterval(() => {
1403
- const currentRun = queries.getRun(runId);
1404
- const elapsed = Date.now() - startTime;
1405
- const done = currentRun && ['success', 'error', 'cancelled'].includes(currentRun.status);
1406
- if (done) {
1407
- clearInterval(pollInterval);
1408
- sendJSON(req, res, 200, currentRun);
1409
- } else if (elapsed > 30000) {
1410
- clearInterval(pollInterval);
1411
- sendJSON(req, res, 408, { error: 'Run still pending after 30s', run_id: runId, status: currentRun?.status || run.status });
1412
- }
1413
- }, 500);
1414
- req.on('close', () => clearInterval(pollInterval));
1415
- return;
1416
- }
1417
-
1418
574
  const oauthHandler = _oauthRoutes._match(req.method, pathOnly);
1419
575
  if (oauthHandler) { await oauthHandler(req, res); return; }
1420
576
 
1421
- const agentAuthMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/auth$/);
1422
- if (agentAuthMatch && req.method === 'POST') {
1423
- const agentId = agentAuthMatch[1];
1424
- const agent = discoveredAgents.find(a => a.id === agentId);
1425
- if (!agent) { sendJSON(req, res, 404, { error: 'Agent not found' }); return; }
577
+ const agentActionsHandler = _agentActionsRoutes._match(req.method, pathOnly);
578
+ if (agentActionsHandler) { await agentActionsHandler(req, res); return; }
1426
579
 
1427
- if (agentId === 'codex' || agentId === 'cli-codex') {
1428
- try {
1429
- const result = await startCodexOAuth(req, { PORT, BASE_URL });
1430
- const conversationId = '__agent_auth__';
1431
- broadcastSync({ type: 'script_started', conversationId, script: 'auth-codex', agentId: 'codex', timestamp: Date.now() });
1432
- broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening OpenAI OAuth in your browser...\x1b[0m\r\n\r\nIf it doesn't open automatically, visit:\r\n${result.authUrl}\r\n`, stream: 'stdout', timestamp: Date.now() });
1433
-
1434
- const pollId = setInterval(() => {
1435
- if (getCodexOAuthState().status === 'success') {
1436
- clearInterval(pollId);
1437
- const email = getCodexOAuthState().email || '';
1438
- broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[32mAuthentication successful${email ? ' (' + email + ')' : ''}\x1b[0m\r\n`, stream: 'stdout', timestamp: Date.now() });
1439
- broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
1440
- } else if (getCodexOAuthState().status === 'error') {
1441
- clearInterval(pollId);
1442
- broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${getCodexOAuthState().error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
1443
- broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: getCodexOAuthState().error, timestamp: Date.now() });
1444
- }
1445
- }, 1000);
1446
-
1447
- setTimeout(() => clearInterval(pollId), 5 * 60 * 1000);
1448
-
1449
- sendJSON(req, res, 200, { ok: true, agentId, authUrl: result.authUrl, mode: result.mode });
1450
- return;
1451
- } catch (e) {
1452
- console.error('[codex-oauth] /api/agents/codex/auth failed:', e);
1453
- sendJSON(req, res, 500, { error: e.message });
1454
- return;
1455
- }
1456
- }
1457
-
1458
- if (agentId === 'gemini') {
1459
- try {
1460
- const result = await startGeminiOAuth(req, { PORT, BASE_URL, rootDir });
1461
- const conversationId = '__agent_auth__';
1462
- broadcastSync({ type: 'script_started', conversationId, script: 'auth-gemini', agentId: 'gemini', timestamp: Date.now() });
1463
- broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening Google OAuth in your browser...\x1b[0m\r\n\r\nIf it doesn't open automatically, visit:\r\n${result.authUrl}\r\n`, stream: 'stdout', timestamp: Date.now() });
1464
-
1465
- const pollId = setInterval(() => {
1466
- if (getGeminiOAuthState().status === 'success') {
1467
- clearInterval(pollId);
1468
- const email = getGeminiOAuthState().email || '';
1469
- broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[32mAuthentication successful${email ? ' (' + email + ')' : ''}\x1b[0m\r\n`, stream: 'stdout', timestamp: Date.now() });
1470
- broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
1471
- } else if (getGeminiOAuthState().status === 'error') {
1472
- clearInterval(pollId);
1473
- broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${getGeminiOAuthState().error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
1474
- broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: getGeminiOAuthState().error, timestamp: Date.now() });
1475
- }
1476
- }, 1000);
1477
-
1478
- setTimeout(() => clearInterval(pollId), 5 * 60 * 1000);
1479
-
1480
- sendJSON(req, res, 200, { ok: true, agentId, authUrl: result.authUrl, mode: result.mode });
1481
- return;
1482
- } catch (e) {
1483
- console.error('[gemini-oauth] /api/agents/gemini/auth failed:', e);
1484
- sendJSON(req, res, 500, { error: e.message });
1485
- return;
1486
- }
1487
- }
1488
-
1489
- const authCommands = {
1490
- 'claude-code': { cmd: 'claude', args: ['setup-token'] },
1491
- 'opencode': { cmd: 'opencode', args: ['auth', 'login'] },
1492
- };
1493
- const authCmd = authCommands[agentId];
1494
- if (!authCmd) { sendJSON(req, res, 400, { error: 'No auth command for this agent' }); return; }
1495
-
1496
- const conversationId = '__agent_auth__';
1497
- if (activeScripts.has(conversationId)) {
1498
- sendJSON(req, res, 409, { error: 'Auth process already running' });
1499
- return;
1500
- }
1501
-
1502
- const child = spawn(authCmd.cmd, authCmd.args, {
1503
- stdio: ['pipe', 'pipe', 'pipe'],
1504
- env: { ...process.env, FORCE_COLOR: '1' },
1505
- shell: os.platform() === 'win32'
1506
- });
1507
- activeScripts.set(conversationId, { process: child, script: 'auth-' + agentId, startTime: Date.now() });
1508
- broadcastSync({ type: 'script_started', conversationId, script: 'auth-' + agentId, agentId, timestamp: Date.now() });
1509
-
1510
- const onData = (stream) => (chunk) => {
1511
- broadcastSync({ type: 'script_output', conversationId, data: chunk.toString(), stream, timestamp: Date.now() });
1512
- };
1513
- child.stdout.on('data', onData('stdout'));
1514
- child.stderr.on('data', onData('stderr'));
1515
- child.stdout.on('error', () => {});
1516
- child.stderr.on('error', () => {});
1517
- child.on('error', (err) => {
1518
- activeScripts.delete(conversationId);
1519
- broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: err.message, timestamp: Date.now() });
1520
- });
1521
- child.on('close', (code) => {
1522
- activeScripts.delete(conversationId);
1523
- broadcastSync({ type: 'script_stopped', conversationId, code: code || 0, timestamp: Date.now() });
1524
- });
1525
- sendJSON(req, res, 200, { ok: true, agentId, pid: child.pid });
1526
- return;
1527
- }
1528
-
1529
- const agentUpdateMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/update$/);
1530
- if (agentUpdateMatch && req.method === 'POST') {
1531
- const agentId = agentUpdateMatch[1];
1532
- const updateCommands = {
1533
- 'claude-code': { cmd: 'claude', args: ['update', '--yes'] },
1534
- };
1535
- const updateCmd = updateCommands[agentId];
1536
- if (!updateCmd) { sendJSON(req, res, 400, { error: 'No update command for this agent' }); return; }
1537
- const conversationId = '__agent_update__';
1538
- if (activeScripts.has(conversationId)) { sendJSON(req, res, 409, { error: 'Update already running' }); return; }
1539
- const child = spawn(updateCmd.cmd, updateCmd.args, {
1540
- stdio: ['pipe', 'pipe', 'pipe'],
1541
- env: { ...process.env, FORCE_COLOR: '1' },
1542
- shell: os.platform() === 'win32'
1543
- });
1544
- activeScripts.set(conversationId, { process: child, script: 'update-' + agentId, startTime: Date.now() });
1545
- broadcastSync({ type: 'script_started', conversationId, script: 'update-' + agentId, agentId, timestamp: Date.now() });
1546
- const onData = (stream) => (chunk) => {
1547
- broadcastSync({ type: 'script_output', conversationId, data: chunk.toString(), stream, timestamp: Date.now() });
1548
- };
1549
- child.stdout.on('data', onData('stdout'));
1550
- child.stderr.on('data', onData('stderr'));
1551
- child.stdout.on('error', () => {});
1552
- child.stderr.on('error', () => {});
1553
- child.on('error', (err) => {
1554
- activeScripts.delete(conversationId);
1555
- broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: err.message, timestamp: Date.now() });
1556
- });
1557
- child.on('close', (code) => {
1558
- activeScripts.delete(conversationId);
1559
- modelCache.delete(agentId);
1560
- broadcastSync({ type: 'script_stopped', conversationId, code: code || 0, timestamp: Date.now() });
1561
- });
1562
- sendJSON(req, res, 200, { ok: true, agentId, pid: child.pid });
1563
- return;
1564
- }
1565
-
1566
- if (pathOnly === '/api/auth/configs' && req.method === 'GET') {
1567
- const configs = getProviderConfigs();
1568
- sendJSON(req, res, 200, configs);
1569
- return;
1570
- }
1571
-
1572
- if (pathOnly === '/api/auth/save-config' && req.method === 'POST') {
1573
- try {
1574
- const body = await parseBody(req);
1575
- const { providerId, apiKey, defaultModel } = body || {};
1576
- if (typeof providerId !== 'string' || !providerId.length || providerId.length > 100) {
1577
- sendJSON(req, res, 400, { error: 'Invalid providerId' }); return;
1578
- }
1579
- if (typeof apiKey !== 'string' || !apiKey.length || apiKey.length > 10000) {
1580
- sendJSON(req, res, 400, { error: 'Invalid apiKey' }); return;
1581
- }
1582
- if (defaultModel !== undefined && (typeof defaultModel !== 'string' || defaultModel.length > 200)) {
1583
- sendJSON(req, res, 400, { error: 'Invalid defaultModel' }); return;
1584
- }
1585
- const configPath = saveProviderConfig(providerId, apiKey, defaultModel || '');
1586
- sendJSON(req, res, 200, { success: true, path: configPath });
1587
- } catch (err) {
1588
- sendJSON(req, res, 400, { error: err.message });
1589
- }
1590
- return;
1591
- }
580
+ const authConfigHandler = _authConfigRoutes._match(req.method, pathOnly);
581
+ if (authConfigHandler) { await authConfigHandler(req, res); return; }
1592
582
 
1593
583
  const speechHandler = _speechRoutes._match(req.method, pathOnly);
1594
584
  if (speechHandler) { await speechHandler(req, res, pathOnly); return; }
@@ -1659,680 +649,6 @@ function serveFile(filePath, res, req) { return _serveFile(filePath, res, req, _
1659
649
 
1660
650
  let broadcastSeq = 0;
1661
651
 
1662
- function parseRateLimitResetTime(text) {
1663
- const match = text.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(?(UTC|[A-Z]{2,4})\)?/i);
1664
- if (!match) return 300;
1665
- let hours = parseInt(match[1], 10);
1666
- const minutes = match[2] ? parseInt(match[2], 10) : 0;
1667
- const period = match[3]?.toLowerCase();
1668
- if (period === 'pm' && hours !== 12) hours += 12;
1669
- if (period === 'am' && hours === 12) hours = 0;
1670
- const now = new Date();
1671
- const resetTime = new Date(now);
1672
- resetTime.setUTCHours(hours, minutes, 0, 0);
1673
- if (resetTime <= now) resetTime.setUTCDate(resetTime.getUTCDate() + 1);
1674
- return Math.max(60, Math.ceil((resetTime.getTime() - now.getTime()) / 1000));
1675
- }
1676
-
1677
- async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId, model, subAgent) {
1678
- const startTime = Date.now();
1679
- touchACP(agentId);
1680
-
1681
- const conv = queries.getConversation(conversationId);
1682
- if (!conv) {
1683
- console.error(`[stream] Conversation ${conversationId} not found, aborting`);
1684
- queries.updateSession(sessionId, { status: 'error', error: 'Conversation not found' });
1685
- queries.setIsStreaming(conversationId, false);
1686
- return;
1687
- }
1688
-
1689
- if (activeExecutions.has(conversationId)) {
1690
- const existing = activeExecutions.get(conversationId);
1691
- if (existing.sessionId !== sessionId) {
1692
- debugLog(`[stream] Conversation ${conversationId} already has active execution (different session), aborting duplicate`);
1693
- return;
1694
- }
1695
- }
1696
-
1697
- if (rateLimitState.has(conversationId)) {
1698
- const rlState = rateLimitState.get(conversationId);
1699
- if (rlState.retryAt > Date.now()) {
1700
- debugLog(`[stream] Conversation ${conversationId} is in rate limit cooldown, aborting`);
1701
- return;
1702
- }
1703
- }
1704
-
1705
- activeExecutions.set(conversationId, { pid: null, startTime, sessionId, lastActivity: startTime });
1706
- execMachine.send(conversationId, { type: 'START', sessionId });
1707
- queries.setIsStreaming(conversationId, true);
1708
- queries.updateSession(sessionId, { status: 'active' });
1709
- const batcher = createChunkBatcher(queries, debugLog);
1710
-
1711
- try {
1712
- debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
1713
-
1714
- const cwd = conv?.workingDirectory || STARTUP_CWD;
1715
- let resumeSessionId = conv?.claudeSessionId || null;
1716
-
1717
- let allBlocks = [];
1718
- let eventCount = 0;
1719
- let currentSequence = queries.getMaxSequence(sessionId) ?? -1;
1720
-
1721
- const onEvent = (parsed) => {
1722
- eventCount++;
1723
- const entry = activeExecutions.get(conversationId);
1724
- if (entry) entry.lastActivity = Date.now();
1725
- if (parsed.session_id) {
1726
- ownedSessionIds.add(parsed.session_id);
1727
- if (!resumeSessionId || resumeSessionId !== parsed.session_id) {
1728
- resumeSessionId = parsed.session_id;
1729
- queries.setClaudeSessionId(conversationId, parsed.session_id, sessionId);
1730
- }
1731
- }
1732
- debugLog(`[stream] Event ${eventCount}: type=${parsed.type}`);
1733
-
1734
- if (parsed.type === 'system') {
1735
- if (parsed.subtype === 'task_notification') return;
1736
- if (!parsed.model && !parsed.cwd && !parsed.tools) return;
1737
-
1738
- const systemBlock = {
1739
- type: 'system',
1740
- subtype: parsed.subtype,
1741
- model: parsed.model,
1742
- cwd: parsed.cwd,
1743
- tools: parsed.tools,
1744
- session_id: parsed.session_id
1745
- };
1746
-
1747
- currentSequence++;
1748
- batcher.add(sessionId, conversationId, currentSequence, 'system', systemBlock);
1749
-
1750
- broadcastSync({
1751
- type: 'streaming_progress',
1752
- sessionId,
1753
- conversationId,
1754
- block: systemBlock,
1755
- blockRole: 'system',
1756
- blockIndex: allBlocks.length,
1757
- seq: currentSequence,
1758
- timestamp: Date.now()
1759
- });
1760
- } else if (parsed.type === 'assistant' && parsed.message?.content) {
1761
- for (const block of parsed.message.content) {
1762
- allBlocks.push(block);
1763
-
1764
- currentSequence++;
1765
- batcher.add(sessionId, conversationId, currentSequence, block.type || 'assistant', block);
1766
-
1767
- broadcastSync({
1768
- type: 'streaming_progress',
1769
- sessionId,
1770
- conversationId,
1771
- block,
1772
- blockRole: 'assistant',
1773
- blockIndex: allBlocks.length - 1,
1774
- seq: currentSequence,
1775
- timestamp: Date.now()
1776
- });
1777
-
1778
- if (block.type === 'text' && block.text) {
1779
- // Check for rate limit message in text content
1780
- const rateLimitTextMatch = block.text.match(/you'?ve hit your limit|rate limit exceeded/i);
1781
- if (rateLimitTextMatch) {
1782
- debugLog(`[rate-limit] Detected rate limit message in stream for conv ${conversationId}`);
1783
-
1784
- const retryAfterSec = parseRateLimitResetTime(block.text);
1785
- debugLog(`[rate-limit] Parsed reset time, retry in ${retryAfterSec}s`);
1786
-
1787
- // Kill the running process
1788
- const entry = activeExecutions.get(conversationId);
1789
- if (entry && entry.pid) {
1790
- try {
1791
- process.kill(entry.pid);
1792
- debugLog(`[rate-limit] Killed process ${entry.pid} for conv ${conversationId}`);
1793
- } catch (e) {
1794
- debugLog(`[rate-limit] Failed to kill process: ${e.message}`);
1795
- }
1796
- }
1797
-
1798
- // Set flag to stop processing and trigger retry
1799
- const existingRetryCount = rateLimitState.get(conversationId)?.retryCount || 0;
1800
- if (existingRetryCount >= 3) {
1801
- debugLog(`[rate-limit] Conv ${conversationId} stream rate limit hit ${existingRetryCount + 1} times, giving up`);
1802
- batcher.drain();
1803
- activeExecutions.delete(conversationId);
1804
- queries.setIsStreaming(conversationId, false);
1805
- const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: Rate limit exceeded after ${existingRetryCount + 1} attempts. Please try again later.`);
1806
- broadcastSync({ type: 'message_created', conversationId, message: errorMessage, timestamp: Date.now() });
1807
- broadcastSync({ type: 'streaming_complete', sessionId, conversationId, interrupted: true, timestamp: Date.now() });
1808
- return;
1809
- }
1810
- rateLimitState.set(conversationId, {
1811
- retryAt: Date.now() + (retryAfterSec * 1000),
1812
- cooldownMs: retryAfterSec * 1000,
1813
- retryCount: existingRetryCount + 1,
1814
- isStreamDetected: true
1815
- });
1816
-
1817
- // Broadcast rate limit event
1818
- broadcastSync({
1819
- type: 'rate_limit_hit',
1820
- sessionId,
1821
- conversationId,
1822
- retryAfterMs: retryAfterSec * 1000,
1823
- retryAt: Date.now() + (retryAfterSec * 1000),
1824
- retryCount: 1,
1825
- timestamp: Date.now()
1826
- });
1827
-
1828
- batcher.drain();
1829
- activeExecutions.delete(conversationId);
1830
- queries.setIsStreaming(conversationId, false);
1831
-
1832
- // Schedule retry
1833
- setTimeout(() => {
1834
- rateLimitState.delete(conversationId);
1835
- broadcastSync({
1836
- type: 'rate_limit_clear',
1837
- conversationId,
1838
- timestamp: Date.now()
1839
- });
1840
- scheduleRetry(conversationId, messageId, content, agentId, model, subAgent);
1841
- }, retryAfterSec * 1000);
1842
-
1843
- return; // Stop processing events
1844
- }
1845
-
1846
- eagerTTS(block.text, conversationId, sessionId);
1847
- }
1848
- }
1849
- } else if (parsed.type === 'user' && parsed.message?.content) {
1850
- for (const block of parsed.message.content) {
1851
- if (block.type === 'tool_result') {
1852
- const toolResultBlock = {
1853
- type: 'tool_result',
1854
- tool_use_id: block.tool_use_id,
1855
- content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
1856
- is_error: block.is_error || false
1857
- };
1858
-
1859
- currentSequence++;
1860
- batcher.add(sessionId, conversationId, currentSequence, 'tool_result', toolResultBlock);
1861
-
1862
- broadcastSync({
1863
- type: 'streaming_progress',
1864
- sessionId,
1865
- conversationId,
1866
- block: toolResultBlock,
1867
- blockRole: 'tool_result',
1868
- blockIndex: allBlocks.length,
1869
- seq: currentSequence,
1870
- timestamp: Date.now()
1871
- });
1872
- }
1873
- }
1874
- } else if (parsed.type === 'result') {
1875
- const resultBlock = {
1876
- type: 'result',
1877
- subtype: parsed.subtype,
1878
- duration_ms: parsed.duration_ms,
1879
- total_cost_usd: parsed.total_cost_usd,
1880
- num_turns: parsed.num_turns,
1881
- is_error: parsed.is_error || false,
1882
- result: parsed.result
1883
- };
1884
-
1885
- currentSequence++;
1886
- batcher.add(sessionId, conversationId, currentSequence, 'result', resultBlock);
1887
-
1888
- broadcastSync({
1889
- type: 'streaming_progress',
1890
- sessionId,
1891
- conversationId,
1892
- block: resultBlock,
1893
- blockRole: 'result',
1894
- blockIndex: allBlocks.length,
1895
- isResult: true,
1896
- seq: currentSequence,
1897
- timestamp: Date.now()
1898
- });
1899
-
1900
- if (parsed.result) {
1901
- const resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
1902
-
1903
- // Check for rate limit message in result
1904
- const rateLimitResultMatch = resultText.match(/you'?ve hit your limit|rate limit exceeded/i);
1905
- if (rateLimitResultMatch) {
1906
- debugLog(`[rate-limit] Detected rate limit in result for conv ${conversationId}`);
1907
-
1908
- const retryAfterSec = parseRateLimitResetTime(resultText);
1909
-
1910
- const entry = activeExecutions.get(conversationId);
1911
- if (entry && entry.pid) {
1912
- try {
1913
- process.kill(entry.pid);
1914
- } catch (e) {}
1915
- }
1916
-
1917
- const existingRetryCount2 = rateLimitState.get(conversationId)?.retryCount || 0;
1918
- if (existingRetryCount2 >= 3) {
1919
- debugLog(`[rate-limit] Conv ${conversationId} result rate limit hit ${existingRetryCount2 + 1} times, giving up`);
1920
- batcher.drain();
1921
- activeExecutions.delete(conversationId);
1922
- queries.setIsStreaming(conversationId, false);
1923
- const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: Rate limit exceeded after ${existingRetryCount2 + 1} attempts. Please try again later.`);
1924
- broadcastSync({ type: 'message_created', conversationId, message: errorMessage, timestamp: Date.now() });
1925
- broadcastSync({ type: 'streaming_complete', sessionId, conversationId, interrupted: true, timestamp: Date.now() });
1926
- return;
1927
- }
1928
- rateLimitState.set(conversationId, {
1929
- retryAt: Date.now() + (retryAfterSec * 1000),
1930
- cooldownMs: retryAfterSec * 1000,
1931
- retryCount: existingRetryCount2 + 1,
1932
- isStreamDetected: true
1933
- });
1934
-
1935
- broadcastSync({
1936
- type: 'rate_limit_hit',
1937
- sessionId,
1938
- conversationId,
1939
- retryAfterMs: retryAfterSec * 1000,
1940
- retryAt: Date.now() + (retryAfterSec * 1000),
1941
- retryCount: existingRetryCount2 + 1,
1942
- timestamp: Date.now()
1943
- });
1944
-
1945
- batcher.drain();
1946
- activeExecutions.delete(conversationId);
1947
- queries.setIsStreaming(conversationId, false);
1948
-
1949
- setTimeout(() => {
1950
- rateLimitState.delete(conversationId);
1951
- broadcastSync({
1952
- type: 'rate_limit_clear',
1953
- conversationId,
1954
- timestamp: Date.now()
1955
- });
1956
- scheduleRetry(conversationId, messageId, content, agentId, model, subAgent);
1957
- }, retryAfterSec * 1000);
1958
-
1959
- return;
1960
- }
1961
-
1962
- if (resultText) eagerTTS(resultText, conversationId, sessionId);
1963
- }
1964
-
1965
- if (parsed.result && allBlocks.length === 0) {
1966
- allBlocks.push({ type: 'text', text: String(parsed.result) });
1967
- }
1968
- } else if (parsed.type === 'tool_status') {
1969
- // Handle ACP tool status updates (in_progress, pending)
1970
- broadcastSync({
1971
- type: 'streaming_progress',
1972
- sessionId,
1973
- conversationId,
1974
- block: {
1975
- type: 'tool_status',
1976
- tool_use_id: parsed.tool_use_id,
1977
- status: parsed.status
1978
- },
1979
- seq: currentSequence,
1980
- timestamp: Date.now()
1981
- });
1982
- } else if (parsed.type === 'usage') {
1983
- // Handle ACP usage updates
1984
- broadcastSync({
1985
- type: 'streaming_progress',
1986
- sessionId,
1987
- conversationId,
1988
- block: {
1989
- type: 'usage',
1990
- usage: parsed.usage
1991
- },
1992
- seq: currentSequence,
1993
- timestamp: Date.now()
1994
- });
1995
- } else if (parsed.type === 'plan') {
1996
- // Handle ACP plan updates
1997
- broadcastSync({
1998
- type: 'streaming_progress',
1999
- sessionId,
2000
- conversationId,
2001
- block: {
2002
- type: 'plan',
2003
- entries: parsed.entries
2004
- },
2005
- seq: currentSequence,
2006
- timestamp: Date.now()
2007
- });
2008
- }
2009
- };
2010
-
2011
- const resolvedModel = model || conv?.model || null;
2012
- const resolvedSubAgent = subAgent || conv?.subAgent || null;
2013
- const unifiedSystemPrompt = buildSystemPrompt(agentId, resolvedModel, resolvedSubAgent);
2014
- const config = {
2015
- verbose: true,
2016
- outputFormat: 'stream-json',
2017
- timeout: 1800000,
2018
- print: true,
2019
- resumeSessionId,
2020
- systemPrompt: unifiedSystemPrompt,
2021
- model: resolvedModel || undefined,
2022
- subAgent: resolvedSubAgent || undefined,
2023
- onEvent,
2024
- onPid: (pid) => {
2025
- const entry = activeExecutions.get(conversationId);
2026
- if (entry) entry.pid = pid;
2027
- execMachine.send(conversationId, { type: 'SET_PID', pid });
2028
- },
2029
- onProcess: (proc) => {
2030
- const entry = activeExecutions.get(conversationId);
2031
- if (entry) entry.proc = proc;
2032
- execMachine.send(conversationId, { type: 'SET_PROC', proc });
2033
- }
2034
- };
2035
-
2036
- // Resolve cli-wrapper agent IDs (e.g. cli-kilo → kilo) to their underlying registered agent
2037
- let resolvedAgentId = agentId || 'claude-code';
2038
- const wrapperAgent = discoveredAgents.find(a => a.id === resolvedAgentId && a.protocol === 'cli-wrapper' && a.acpId);
2039
- if (wrapperAgent) resolvedAgentId = wrapperAgent.acpId;
2040
-
2041
- const { outputs, sessionId: claudeSessionId } = await runClaudeWithStreaming(content, cwd, resolvedAgentId, config);
2042
-
2043
- // Check if rate limit was already handled in stream detection
2044
- if (rateLimitState.get(conversationId)?.isStreamDetected) {
2045
- debugLog(`[rate-limit] Rate limit already handled in stream for conv ${conversationId}, skipping success handler`);
2046
- return;
2047
- }
2048
-
2049
- activeExecutions.delete(conversationId);
2050
- execMachine.send(conversationId, { type: 'COMPLETE' });
2051
- batcher.drain();
2052
- if (claudeSessionId) ownedSessionIds.delete(claudeSessionId);
2053
- debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
2054
-
2055
- debugLog(`[stream] Keeping claudeSessionId=${claudeSessionId} for session continuity`);
2056
-
2057
- // Mark session as complete
2058
- queries.updateSession(sessionId, {
2059
- status: 'complete',
2060
- response: JSON.stringify({ outputs, eventCount }),
2061
- completed_at: Date.now()
2062
- });
2063
-
2064
- broadcastSync({
2065
- type: 'streaming_complete',
2066
- sessionId,
2067
- conversationId,
2068
- agentId,
2069
- eventCount,
2070
- seq: currentSequence,
2071
- timestamp: Date.now()
2072
- });
2073
-
2074
- debugLog(`[stream] Completed: ${outputs.length} outputs, ${eventCount} events`);
2075
- } catch (error) {
2076
- const elapsed = Date.now() - startTime;
2077
- debugLog(`[stream] Error after ${elapsed}ms: ${error.message}`);
2078
- const conv2 = queries.getConversation(conversationId);
2079
- if (conv2?.claudeSessionId) ownedSessionIds.delete(conv2.claudeSessionId);
2080
-
2081
- // Check if rate limit was already handled in stream detection
2082
- const existingState = rateLimitState.get(conversationId);
2083
- if (existingState?.isStreamDetected) {
2084
- debugLog(`[rate-limit] Rate limit already handled in stream for conv ${conversationId}, skipping catch handler`);
2085
- return;
2086
- }
2087
-
2088
- const isAuthError = error.authError || error.nonRetryable ||
2089
- /401|unauthorized|invalid.*auth|invalid.*token|auth.*failed|permission denied|access denied/i.test(error.message);
2090
-
2091
- const isRateLimit = error.rateLimited ||
2092
- /rate.?limit|429|too many requests|overloaded|throttl/i.test(error.message);
2093
-
2094
- queries.updateSession(sessionId, {
2095
- status: 'error',
2096
- error: error.message,
2097
- completed_at: Date.now()
2098
- });
2099
-
2100
- if (isAuthError) {
2101
- debugLog(`[auth-error] Auth error for conv ${conversationId}: ${error.message}`);
2102
- broadcastSync({
2103
- type: 'streaming_error',
2104
- sessionId,
2105
- conversationId,
2106
- error: `Authentication failed: ${error.message}. Please check your API credentials.`,
2107
- recoverable: false,
2108
- isAuthError: true,
2109
- timestamp: Date.now()
2110
- });
2111
- const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: Authentication failed. ${error.message}. Please update your credentials and try again.`);
2112
- broadcastSync({
2113
- type: 'message_created',
2114
- conversationId,
2115
- message: errorMessage,
2116
- timestamp: Date.now()
2117
- });
2118
- queries.setIsStreaming(conversationId, false);
2119
- batcher.drain();
2120
- activeExecutions.delete(conversationId);
2121
- return;
2122
- }
2123
-
2124
- if (isRateLimit) {
2125
- const existingState = rateLimitState.get(conversationId) || {};
2126
- const retryCount = (existingState.retryCount || 0) + 1;
2127
- const maxRateLimitRetries = 3;
2128
-
2129
- if (retryCount > maxRateLimitRetries) {
2130
- debugLog(`[rate-limit] Conv ${conversationId} hit rate limit ${retryCount} times, giving up`);
2131
- broadcastSync({
2132
- type: 'streaming_error',
2133
- sessionId,
2134
- conversationId,
2135
- error: `Rate limit exceeded after ${retryCount} attempts. Please try again later.`,
2136
- recoverable: false,
2137
- timestamp: Date.now()
2138
- });
2139
- const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: Rate limit exceeded after ${retryCount} attempts. Please try again later.`);
2140
- broadcastSync({
2141
- type: 'message_created',
2142
- conversationId,
2143
- message: errorMessage,
2144
- timestamp: Date.now()
2145
- });
2146
- queries.setIsStreaming(conversationId, false);
2147
- return;
2148
- }
2149
-
2150
- const cooldownMs = (error.retryAfterSec || 60) * 1000;
2151
- const retryAt = Date.now() + cooldownMs;
2152
- rateLimitState.set(conversationId, { retryAt, cooldownMs, retryCount });
2153
- debugLog(`[rate-limit] Conv ${conversationId} hit rate limit (attempt ${retryCount}/${maxRateLimitRetries}), retry in ${cooldownMs}ms`);
2154
-
2155
- broadcastSync({
2156
- type: 'rate_limit_hit',
2157
- sessionId,
2158
- conversationId,
2159
- retryAfterMs: cooldownMs,
2160
- retryAt,
2161
- retryCount,
2162
- timestamp: Date.now()
2163
- });
2164
-
2165
- batcher.drain();
2166
-
2167
- debugLog(`[rate-limit] Scheduling retry for conv ${conversationId} in ${cooldownMs}ms (attempt ${retryCount + 1})`);
2168
-
2169
- setTimeout(() => {
2170
- debugLog(`[rate-limit] Timeout fired for conv ${conversationId}, calling scheduleRetry`);
2171
- rateLimitState.delete(conversationId);
2172
- debugLog(`[rate-limit] Conv ${conversationId} cooldown expired, restarting (attempt ${retryCount + 1})`);
2173
- broadcastSync({
2174
- type: 'rate_limit_clear',
2175
- conversationId,
2176
- timestamp: Date.now()
2177
- });
2178
- scheduleRetry(conversationId, messageId, content, agentId, model, subAgent);
2179
- }, cooldownMs);
2180
- return;
2181
- }
2182
-
2183
- const isSessionConflict = error.exitCode === null && eventCount === 0;
2184
-
2185
- broadcastSync({
2186
- type: 'streaming_error',
2187
- sessionId,
2188
- conversationId,
2189
- error: error.message,
2190
- isPrematureEnd: error.isPrematureEnd || false,
2191
- exitCode: error.exitCode,
2192
- stderrText: error.stderrText,
2193
- recoverable: elapsed < 60000,
2194
- isSessionConflict,
2195
- timestamp: Date.now()
2196
- });
2197
-
2198
- if (!isSessionConflict) {
2199
- const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: ${error.message}`);
2200
- broadcastSync({
2201
- type: 'message_created',
2202
- conversationId,
2203
- message: errorMessage,
2204
- timestamp: Date.now()
2205
- });
2206
- }
2207
- } finally {
2208
- batcher.drain();
2209
- // Use atomic cleanup but only if not in rate limit recovery
2210
- if (!rateLimitState.has(conversationId)) {
2211
- cleanupExecution(conversationId);
2212
- drainMessageQueue(conversationId);
2213
- }
2214
- }
2215
- }
2216
-
2217
- function scheduleRetry(conversationId, messageId, content, agentId, model, subAgent) {
2218
- debugLog(`[rate-limit] scheduleRetry called for conv ${conversationId}, messageId=${messageId}`);
2219
-
2220
- if (!content) {
2221
- const conv = queries.getConversation(conversationId);
2222
- const lastMsg = queries.getLastUserMessage(conversationId);
2223
- content = lastMsg?.content || 'continue';
2224
- debugLog(`[rate-limit] Recovered content from last message: ${content?.substring?.(0, 50)}...`);
2225
- }
2226
-
2227
- const newSession = queries.createSession(conversationId);
2228
- queries.createEvent('session.created', { messageId, sessionId: newSession.id, retryReason: 'rate_limit' }, conversationId, newSession.id);
2229
-
2230
- debugLog(`[rate-limit] Broadcasting streaming_start for retry session ${newSession.id}`);
2231
- broadcastSync({
2232
- type: 'streaming_start',
2233
- sessionId: newSession.id,
2234
- conversationId,
2235
- messageId,
2236
- agentId,
2237
- queueLength: messageQueues.get(conversationId)?.length || 0,
2238
- timestamp: Date.now()
2239
- });
2240
-
2241
- const startTime = Date.now();
2242
- activeExecutions.set(conversationId, { pid: null, startTime, sessionId: newSession.id, lastActivity: startTime });
2243
-
2244
- debugLog(`[rate-limit] Calling processMessageWithStreaming for retry`);
2245
- processMessageWithStreaming(conversationId, messageId, newSession.id, content, agentId, model, subAgent)
2246
- .catch(err => {
2247
- debugLog(`[rate-limit] Retry failed: ${err.message}`);
2248
- console.error(`[rate-limit] Retry error for conv ${conversationId}:`, err);
2249
- // Clean up state on retry failure
2250
- cleanupExecution(conversationId);
2251
- broadcastSync({
2252
- type: 'streaming_error',
2253
- sessionId: newSession.id,
2254
- conversationId,
2255
- error: `Rate limit retry failed: ${err.message}`,
2256
- recoverable: false,
2257
- timestamp: Date.now()
2258
- });
2259
- });
2260
- }
2261
-
2262
- function drainMessageQueue(conversationId) {
2263
- // Machine context queue is authoritative; fall back to legacy Map
2264
- const machineQueue = execMachine.getQueue(conversationId);
2265
- const mapQueue = messageQueues.get(conversationId);
2266
- if (machineQueue.length === 0 && (!mapQueue || mapQueue.length === 0)) return;
2267
-
2268
- let next;
2269
- if (machineQueue.length > 0) {
2270
- // Consume from machine via COMPLETE transition (draining state pops nextItem)
2271
- execMachine.send(conversationId, { type: 'COMPLETE' });
2272
- const ctx = execMachine.getContext(conversationId);
2273
- next = ctx?.nextItem;
2274
- // Also keep Map in sync
2275
- if (mapQueue && mapQueue.length > 0) mapQueue.shift();
2276
- if (mapQueue && mapQueue.length === 0) messageQueues.delete(conversationId);
2277
- } else {
2278
- next = mapQueue.shift();
2279
- if (mapQueue.length === 0) messageQueues.delete(conversationId);
2280
- }
2281
-
2282
- if (!next) return;
2283
-
2284
- debugLog(`[queue] Draining next message for ${conversationId}, messageId=${next.messageId}`);
2285
-
2286
- const remainingQueueLength = execMachine.getQueue(conversationId).length || messageQueues.get(conversationId)?.length || 0;
2287
-
2288
- broadcastSync({
2289
- type: 'queue_item_dequeued',
2290
- conversationId,
2291
- messageId: next.messageId,
2292
- queueLength: remainingQueueLength,
2293
- timestamp: Date.now()
2294
- });
2295
-
2296
- const session = queries.createSession(conversationId);
2297
- queries.createEvent('session.created', { messageId: next.messageId, sessionId: session.id }, conversationId, session.id);
2298
-
2299
- broadcastSync({
2300
- type: 'streaming_start',
2301
- sessionId: session.id,
2302
- conversationId,
2303
- messageId: next.messageId,
2304
- agentId: next.agentId,
2305
- queueLength: remainingQueueLength,
2306
- timestamp: Date.now()
2307
- });
2308
-
2309
- broadcastSync({
2310
- type: 'queue_status',
2311
- conversationId,
2312
- queueLength: remainingQueueLength,
2313
- timestamp: Date.now()
2314
- });
2315
-
2316
- const startTime = Date.now();
2317
- // Machine START event makes machine authoritative for this execution
2318
- execMachine.send(conversationId, { type: 'START', sessionId: session.id });
2319
- activeExecutions.set(conversationId, { pid: null, startTime, sessionId: session.id, lastActivity: startTime });
2320
-
2321
- processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.model, next.subAgent)
2322
- .catch(err => {
2323
- debugLog(`[queue] Error processing queued message: ${err.message}`);
2324
- cleanupExecution(conversationId);
2325
- broadcastSync({
2326
- type: 'streaming_error',
2327
- sessionId: session.id,
2328
- conversationId,
2329
- error: `Queue processing failed: ${err.message}`,
2330
- recoverable: true,
2331
- timestamp: Date.now()
2332
- });
2333
- setTimeout(() => drainMessageQueue(conversationId), 100);
2334
- });
2335
- }
2336
652
 
2337
653
 
2338
654
  const wss = new WebSocketServer({
@@ -2425,6 +741,23 @@ const broadcastSync = createBroadcast({
2425
741
  getSeq: () => ++broadcastSeq
2426
742
  });
2427
743
 
744
+ // Wire up process-message factories now that broadcastSync and all deps are available
745
+ const _mqDeps = {
746
+ queries, messageQueues, activeExecutions, rateLimitState, execMachine,
747
+ broadcastSync, cleanupExecution, debugLog,
748
+ getProcessMessageWithStreaming: () => processMessageWithStreaming
749
+ };
750
+ const { scheduleRetry, drainMessageQueue } = createMessageQueue(_mqDeps);
751
+
752
+ const { processMessageWithStreaming } = createProcessMessage({
753
+ queries, activeExecutions, rateLimitState, execMachine,
754
+ broadcastSync, runClaudeWithStreaming, cleanupExecution, checkpointManager,
755
+ discoveredAgents, ownedSessionIds, STARTUP_CWD, buildSystemPrompt,
756
+ parseRateLimitResetTime, eagerTTS, touchACP, createChunkBatcher,
757
+ debugLog, logError,
758
+ scheduleRetry, drainMessageQueue, createEventHandler
759
+ });
760
+
2428
761
  // WebSocket protocol router
2429
762
  const wsRouter = new WsRouter();
2430
763
 
@@ -2437,6 +770,12 @@ const _threadRoutes = registerThreadRoutes({ sendJSON, parseBody, queries });
2437
770
  const _debugRoutes = registerDebugRoutes({ sendJSON, queries, activeExecutions, messageQueues, syncClients, wsOptimizer, _errLogPath });
2438
771
  const _convRoutes = registerConvRoutes({ sendJSON, parseBody, queries, activeExecutions, broadcastSync });
2439
772
  const _agentRoutes = registerAgentRoutes({ sendJSON, parseBody, queries, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, debugLog });
773
+ const _messagesRoutes = registerMessagesRoutes({ queries, sendJSON, parseBody, broadcastSync, processMessageWithStreaming, activeExecutions, messageQueues, debugLog, logError });
774
+ const _sessionsRoutes = registerSessionsRoutes({ queries, sendJSON, activeExecutions, rateLimitState, debugLog });
775
+ const _runsRoutes = registerRunsRoutes({ sendJSON, parseBody, queries, broadcastSync, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, discoveredAgents, STARTUP_CWD });
776
+ const _scriptsRoutes = registerScriptsRoutes({ sendJSON, parseBody, queries, broadcastSync, activeScripts, activeExecutions, processMessageWithStreaming, STARTUP_CWD });
777
+ const _agentActionsRoutes = registerAgentActionsRoutes({ sendJSON, queries, broadcastSync, discoveredAgents, activeScripts, startGeminiOAuth, startCodexOAuth, getGeminiOAuthState, getCodexOAuthState, modelCache, PORT, BASE_URL, rootDir });
778
+ const _authConfigRoutes = registerAuthConfigRoutes({ sendJSON, parseBody, getProviderConfigs, saveProviderConfig });
2440
779
 
2441
780
  registerConvHandlers(wsRouter, {
2442
781
  queries, activeExecutions, rateLimitState,