@jait/gateway 0.1.227 → 0.1.229

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.
Files changed (83) hide show
  1. package/dist/providers/jait-provider.d.ts +2 -0
  2. package/dist/providers/jait-provider.d.ts.map +1 -1
  3. package/dist/providers/jait-provider.js +16 -0
  4. package/dist/providers/jait-provider.js.map +1 -1
  5. package/dist/routes/auth.d.ts.map +1 -1
  6. package/dist/routes/auth.js +52 -12
  7. package/dist/routes/auth.js.map +1 -1
  8. package/dist/routes/chat.d.ts.map +1 -1
  9. package/dist/routes/chat.js +302 -288
  10. package/dist/routes/chat.js.map +1 -1
  11. package/dist/routes/threads.d.ts.map +1 -1
  12. package/dist/routes/threads.js +13 -1
  13. package/dist/routes/threads.js.map +1 -1
  14. package/dist/security/http-auth.d.ts +1 -0
  15. package/dist/security/http-auth.d.ts.map +1 -1
  16. package/dist/security/http-auth.js +3 -1
  17. package/dist/security/http-auth.js.map +1 -1
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/server.js +3 -0
  20. package/dist/server.js.map +1 -1
  21. package/dist/surfaces/terminal.d.ts.map +1 -1
  22. package/dist/surfaces/terminal.js +1 -0
  23. package/dist/surfaces/terminal.js.map +1 -1
  24. package/dist/ws.d.ts +1 -0
  25. package/dist/ws.d.ts.map +1 -1
  26. package/dist/ws.js +9 -1
  27. package/dist/ws.js.map +1 -1
  28. package/package.json +3 -3
  29. package/web-dist/assets/{_basePickBy-5TWDl4JM.js → _basePickBy-Y4-sJhMJ.js} +1 -1
  30. package/web-dist/assets/{_baseUniq-CMQOESlr.js → _baseUniq-BFWigmYH.js} +1 -1
  31. package/web-dist/assets/{arc-M3t7zaj1.js → arc-DItQgg97.js} +1 -1
  32. package/web-dist/assets/{architectureDiagram-2XIMDMQ5-c9m8SS0c.js → architectureDiagram-2XIMDMQ5-CGgl58gT.js} +1 -1
  33. package/web-dist/assets/{blockDiagram-WCTKOSBZ-DqUqNIzU.js → blockDiagram-WCTKOSBZ-D-WlS5xD.js} +1 -1
  34. package/web-dist/assets/{c4Diagram-IC4MRINW-CNzQ33JK.js → c4Diagram-IC4MRINW-CdG8FSdv.js} +1 -1
  35. package/web-dist/assets/channel-Cyxt55lU.js +1 -0
  36. package/web-dist/assets/{chunk-4BX2VUAB-COuOVu1p.js → chunk-4BX2VUAB-BL-i6cr8.js} +1 -1
  37. package/web-dist/assets/{chunk-55IACEB6-CDTARcKO.js → chunk-55IACEB6-D-WoPZ_Q.js} +1 -1
  38. package/web-dist/assets/{chunk-FMBD7UC4-BF2uRoZ2.js → chunk-FMBD7UC4-DLwJx6mv.js} +1 -1
  39. package/web-dist/assets/{chunk-JSJVCQXG-DbnYPeoT.js → chunk-JSJVCQXG-H-QtRq-_.js} +1 -1
  40. package/web-dist/assets/{chunk-KX2RTZJC-Tim2NTNu.js → chunk-KX2RTZJC-0OIICzqR.js} +1 -1
  41. package/web-dist/assets/{chunk-NQ4KR5QH-ZvRCCrAp.js → chunk-NQ4KR5QH-BFrfoApj.js} +1 -1
  42. package/web-dist/assets/{chunk-QZHKN3VN-nA0PVVcm.js → chunk-QZHKN3VN-DhOr0Y5J.js} +1 -1
  43. package/web-dist/assets/{chunk-WL4C6EOR-B9-vmYuf.js → chunk-WL4C6EOR-BER0qM1r.js} +1 -1
  44. package/web-dist/assets/classDiagram-VBA2DB6C-YPDYVFoc.js +1 -0
  45. package/web-dist/assets/classDiagram-v2-RAHNMMFH-YPDYVFoc.js +1 -0
  46. package/web-dist/assets/clone-lVvDwdQf.js +1 -0
  47. package/web-dist/assets/{cose-bilkent-S5V4N54A-B7pE6AKK.js → cose-bilkent-S5V4N54A-BKpddDxN.js} +1 -1
  48. package/web-dist/assets/{dagre-KLK3FWXG-B-PVXqSH.js → dagre-KLK3FWXG-Ny_S7TzO.js} +1 -1
  49. package/web-dist/assets/{diagram-E7M64L7V-QEmBAviP.js → diagram-E7M64L7V-DnwGN2nP.js} +1 -1
  50. package/web-dist/assets/{diagram-IFDJBPK2-mEsLVcNK.js → diagram-IFDJBPK2-D61YyDHG.js} +1 -1
  51. package/web-dist/assets/{diagram-P4PSJMXO-Da2wOIVw.js → diagram-P4PSJMXO-CPAi1bMg.js} +1 -1
  52. package/web-dist/assets/{erDiagram-INFDFZHY-Dqhl3IbI.js → erDiagram-INFDFZHY-BW_3YxkM.js} +1 -1
  53. package/web-dist/assets/{flowDiagram-PKNHOUZH-B3xwnDF3.js → flowDiagram-PKNHOUZH-D4fIoGDZ.js} +1 -1
  54. package/web-dist/assets/{ganttDiagram-A5KZAMGK-CfCW_MyX.js → ganttDiagram-A5KZAMGK-Bgejn6fJ.js} +1 -1
  55. package/web-dist/assets/{gitGraphDiagram-K3NZZRJ6-Rf_I-a2c.js → gitGraphDiagram-K3NZZRJ6-D_hGBsYv.js} +1 -1
  56. package/web-dist/assets/{graph-lq7-soOy.js → graph-Cr6kCiTF.js} +1 -1
  57. package/web-dist/assets/{index-CvzAVoMc.js → index-Bb-icMtw.js} +320 -320
  58. package/web-dist/assets/index-COvsYl9M.css +32 -0
  59. package/web-dist/assets/{infoDiagram-LFFYTUFH-DdNDArV9.js → infoDiagram-LFFYTUFH-DJr3cor-.js} +1 -1
  60. package/web-dist/assets/{ishikawaDiagram-PHBUUO56-CVZeeAPF.js → ishikawaDiagram-PHBUUO56-eq7b9gMG.js} +1 -1
  61. package/web-dist/assets/{journeyDiagram-4ABVD52K-BOChQ2Bs.js → journeyDiagram-4ABVD52K-B7G9MecY.js} +1 -1
  62. package/web-dist/assets/{kanban-definition-K7BYSVSG-BeiHTl71.js → kanban-definition-K7BYSVSG-GSTXE3lX.js} +1 -1
  63. package/web-dist/assets/{layout-CM3LVb57.js → layout-BujL5zIm.js} +1 -1
  64. package/web-dist/assets/{linear-CzlIHkfY.js → linear-C6WRDsJX.js} +1 -1
  65. package/web-dist/assets/{mindmap-definition-YRQLILUH-dsoQ6qZM.js → mindmap-definition-YRQLILUH-zeolX9nH.js} +1 -1
  66. package/web-dist/assets/{pieDiagram-SKSYHLDU-B2YujYSK.js → pieDiagram-SKSYHLDU-BkDkyYLU.js} +1 -1
  67. package/web-dist/assets/{quadrantDiagram-337W2JSQ-CfRx6Vxe.js → quadrantDiagram-337W2JSQ-D2ZadOZg.js} +1 -1
  68. package/web-dist/assets/{requirementDiagram-Z7DCOOCP-CwxbIP8g.js → requirementDiagram-Z7DCOOCP-C3bismXb.js} +1 -1
  69. package/web-dist/assets/{sankeyDiagram-WA2Y5GQK-RTlZrggt.js → sankeyDiagram-WA2Y5GQK-HGrQ6VhU.js} +1 -1
  70. package/web-dist/assets/{sequenceDiagram-2WXFIKYE-C6ExE-CT.js → sequenceDiagram-2WXFIKYE-BCAUdUtW.js} +1 -1
  71. package/web-dist/assets/{stateDiagram-RAJIS63D-yIq6UCoM.js → stateDiagram-RAJIS63D-CQqZ1lYn.js} +1 -1
  72. package/web-dist/assets/stateDiagram-v2-FVOUBMTO-CJXglv7T.js +1 -0
  73. package/web-dist/assets/{timeline-definition-YZTLITO2-BXlhV5HG.js → timeline-definition-YZTLITO2-BE5v6UP8.js} +1 -1
  74. package/web-dist/assets/{treemap-KZPCXAKY-DLKOCiK9.js → treemap-KZPCXAKY-D5fESSPe.js} +1 -1
  75. package/web-dist/assets/{vennDiagram-LZ73GAT5-DNrWcYRu.js → vennDiagram-LZ73GAT5-5M80z6e-.js} +1 -1
  76. package/web-dist/assets/{xychartDiagram-JWTSCODW-CniBBtHN.js → xychartDiagram-JWTSCODW-3ZvKqnud.js} +1 -1
  77. package/web-dist/index.html +2 -2
  78. package/web-dist/assets/channel-8ylz1LZN.js +0 -1
  79. package/web-dist/assets/classDiagram-VBA2DB6C-CVI5svHi.js +0 -1
  80. package/web-dist/assets/classDiagram-v2-RAHNMMFH-CVI5svHi.js +0 -1
  81. package/web-dist/assets/clone-CF_hu4RI.js +0 -1
  82. package/web-dist/assets/index-BMzEl1co.css +0 -32
  83. package/web-dist/assets/stateDiagram-v2-FVOUBMTO-WZobr7ge.js +0 -1
@@ -687,7 +687,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
687
687
  ? body["session_id"]
688
688
  : randomUUID();
689
689
  const chatMode = isValidChatMode(body["mode"]) ? body["mode"] : "agent";
690
- const requestProvider = typeof body["provider"] === "string"
690
+ let requestProvider = typeof body["provider"] === "string"
691
691
  ? body["provider"]
692
692
  : undefined;
693
693
  const requestRuntimeMode = parseRuntimeMode(body["runtimeMode"]);
@@ -717,12 +717,21 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
717
717
  }
718
718
  }
719
719
  const userApiKeys = userService?.getSettings(authUser.id).apiKeys ?? {};
720
- const effectiveModel = userApiKeys["OPENAI_MODEL"]?.trim() || config.openaiModel;
720
+ const requestBodyModel = typeof body["model"] === "string" ? body["model"].trim() : "";
721
+ const effectiveModel = requestBodyModel || userApiKeys["OPENAI_MODEL"]?.trim() || config.openaiModel;
722
+ // When the selected model looks like an OpenRouter model (contains "/"),
723
+ // route through OpenRouter using the user's OpenRouter API key.
724
+ const isOpenRouterModel = effectiveModel.includes("/");
725
+ const openRouterKey = userApiKeys["OPENROUTER_API_KEY"]?.trim();
721
726
  const llmRuntime = {
722
- openaiApiKey: userApiKeys["OPENAI_API_KEY"]?.trim() || config.openaiApiKey,
723
- openaiBaseUrl: userApiKeys["OPENAI_BASE_URL"]?.trim() || config.openaiBaseUrl,
727
+ openaiApiKey: isOpenRouterModel && openRouterKey
728
+ ? openRouterKey
729
+ : (userApiKeys["OPENAI_API_KEY"]?.trim() || config.openaiApiKey),
730
+ openaiBaseUrl: isOpenRouterModel && openRouterKey
731
+ ? "https://openrouter.ai/api/v1"
732
+ : (userApiKeys["OPENAI_BASE_URL"]?.trim() || config.openaiBaseUrl),
724
733
  openaiModel: effectiveModel,
725
- contextWindow: userApiKeys["OPENAI_MODEL"]?.trim()
734
+ contextWindow: (requestBodyModel || userApiKeys["OPENAI_MODEL"]?.trim())
726
735
  ? inferContextWindow(effectiveModel)
727
736
  : config.contextWindow,
728
737
  };
@@ -828,6 +837,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
828
837
  const steering = new SteeringController();
829
838
  sessionSteeringControllers.set(sessionId, steering);
830
839
  try {
840
+ let usedCliProvider = false;
831
841
  // ══ CLI Provider path (codex / claude-code via MCP) ══════════
832
842
  if (requestProvider && requestProvider !== "jait" && providerRegistry) {
833
843
  const cliWsRoot = surfaceRegistry
@@ -869,317 +879,321 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
869
879
  const runtimeMode = resolveProviderRuntimeMode(cliProvider, requestRuntimeMode);
870
880
  const available = await cliProvider.checkAvailability();
871
881
  if (!available) {
872
- safeWrite(`data: ${JSON.stringify({ type: "error", message: `Provider ${requestProvider} is not available${isRemote ? " on the remote node" : ""}: ${cliProvider.info.unavailableReason ?? "CLI not found"}` })}\n\n`);
873
- reply.raw.end();
874
- return;
875
- }
876
- console.log(`[chat/cli] session=${sessionId} wsRoot="${cliWsRoot}" session.workspacePath="${sessionRecord?.workspacePath}" surfaces=${surfaceRegistry?.getBySession(sessionId)?.length ?? 0}`);
877
- // Ensure a FileSystemSurface exists for this session so we can
878
- // back up files before CLI providers (Codex/Claude) write them,
879
- // enabling the keep/discard (undo) flow.
880
- // Use _skipBroadcast so the UI doesn't open the workspace panel.
881
- let cliFsSurface = null;
882
- if (surfaceRegistry) {
883
- const fsId = `fs-${sessionId}`;
884
- const existing = surfaceRegistry.getSurface(fsId);
885
- if (existing instanceof FileSystemSurface && existing.state === "running") {
886
- cliFsSurface = existing;
887
- }
888
- else {
889
- try {
890
- const prevHandler = surfaceRegistry.onSurfaceStarted;
891
- surfaceRegistry.onSurfaceStarted = null;
892
- const started = await surfaceRegistry.startSurface("filesystem", fsId, {
893
- sessionId,
894
- workspaceRoot: cliWsRoot,
895
- });
896
- surfaceRegistry.onSurfaceStarted = prevHandler;
897
- cliFsSurface = started;
898
- }
899
- catch { /* best effort */ }
900
- }
901
- }
902
- const mcpServers = [providerRegistry.buildJaitMcpServerRef(config, getRequestBaseUrl(request), {
903
- sessionId,
904
- workspaceRoot: cliWsRoot,
905
- })];
906
- // ── Reuse an existing CLI session if one is alive for this Jait session ──
907
- const cachedCliSession = activeCliSessions.get(sessionId);
908
- let providerSessionId;
909
- if (cachedCliSession && cachedCliSession.providerId === requestProvider && cachedCliSession.runtimeMode === runtimeMode) {
910
- // Existing session with the same provider — try to reuse it
911
- providerSessionId = cachedCliSession.providerSessionId;
912
- cliProvider = cachedCliSession.provider;
913
- console.log(`[chat/cli] Reusing ${requestProvider}/${runtimeMode} session ${providerSessionId} for ${sessionId}`);
882
+ // Provider is offline or not installed fall back to Jait
883
+ const reason = cliProvider.info.unavailableReason ?? "CLI not found";
884
+ console.log(`[chat/cli] Provider ${requestProvider} unavailable (${reason}), falling back to jait`);
885
+ safeWrite(`data: ${JSON.stringify({ type: "provider_fallback", from: requestProvider, to: "jait", reason })}\n\n`);
914
886
  }
915
887
  else {
916
- // If the user switched providers, stop the old session first
917
- if (cachedCliSession) {
918
- try {
919
- await cachedCliSession.provider.stopSession(cachedCliSession.providerSessionId);
888
+ console.log(`[chat/cli] session=${sessionId} wsRoot="${cliWsRoot}" session.workspacePath="${sessionRecord?.workspacePath}" surfaces=${surfaceRegistry?.getBySession(sessionId)?.length ?? 0}`);
889
+ // Ensure a FileSystemSurface exists for this session so we can
890
+ // back up files before CLI providers (Codex/Claude) write them,
891
+ // enabling the keep/discard (undo) flow.
892
+ // Use _skipBroadcast so the UI doesn't open the workspace panel.
893
+ let cliFsSurface = null;
894
+ if (surfaceRegistry) {
895
+ const fsId = `fs-${sessionId}`;
896
+ const existing = surfaceRegistry.getSurface(fsId);
897
+ if (existing instanceof FileSystemSurface && existing.state === "running") {
898
+ cliFsSurface = existing;
899
+ }
900
+ else {
901
+ try {
902
+ const prevHandler = surfaceRegistry.onSurfaceStarted;
903
+ surfaceRegistry.onSurfaceStarted = null;
904
+ const started = await surfaceRegistry.startSurface("filesystem", fsId, {
905
+ sessionId,
906
+ workspaceRoot: cliWsRoot,
907
+ });
908
+ surfaceRegistry.onSurfaceStarted = prevHandler;
909
+ cliFsSurface = started;
910
+ }
911
+ catch { /* best effort */ }
920
912
  }
921
- catch { /* best effort */ }
922
- activeCliSessions.delete(sessionId);
923
- }
924
- const session = await cliProvider.startSession({
925
- threadId: sessionId,
926
- workingDirectory: cliWsRoot,
927
- mode: runtimeMode,
928
- model: typeof body["model"] === "string" ? body["model"] : undefined,
929
- mcpServers,
930
- });
931
- providerSessionId = session.id;
932
- activeCliSessions.set(sessionId, { providerId: requestProvider, runtimeMode, providerSessionId, provider: cliProvider });
933
- console.log(`[chat/cli] Started new ${requestProvider}/${runtimeMode}${isRemote ? " (remote)" : ""} session ${providerSessionId} for ${sessionId}`);
934
- }
935
- // Tell the frontend about the execution context (node, workspace)
936
- safeWrite(`data: ${JSON.stringify({
937
- type: "session_info",
938
- provider: requestProvider,
939
- workspacePath: cliWsRoot,
940
- isRemote,
941
- ...(remoteNodeInfo ? { remoteNode: remoteNodeInfo } : {}),
942
- })}\n\n`);
943
- // Collect full content from CLI provider events
944
- const contentChunks = [];
945
- /** Bytes received via streaming `token` events for the current message block.
946
- * Reset when a tool starts so we can correctly detect the next message block. */
947
- let tokenBytesThisBlock = 0;
948
- // ── Accumulate tool calls + segments for persistence ──
949
- const cliToolCalls = [];
950
- const cliSegments = [];
951
- /** Track the current pending tool-group callIds (batched between text tokens) */
952
- let pendingToolGroup = [];
953
- let lastSegmentWasText = false;
954
- /** Flush any buffered text into a text segment */
955
- const flushTextSegment = () => {
956
- if (lastSegmentWasText)
957
- return; // already flushed
958
- const text = contentChunks.join("");
959
- // Only create a segment if there's new text since the last tool group
960
- const prevTextLen = cliSegments
961
- .filter((s) => s.type === "text")
962
- .reduce((n, s) => n + s.content.length, 0);
963
- const newText = text.slice(prevTextLen);
964
- if (newText) {
965
- cliSegments.push({ type: "text", content: newText });
966
913
  }
967
- lastSegmentWasText = true;
968
- };
969
- /** Flush any pending tool group into a segment */
970
- const flushToolGroup = () => {
971
- if (pendingToolGroup.length > 0) {
972
- // Before adding a tool group, flush any preceding text
973
- flushTextSegment();
974
- cliSegments.push({ type: "toolGroup", callIds: [...pendingToolGroup] });
975
- pendingToolGroup = [];
976
- lastSegmentWasText = false;
914
+ const mcpServers = [providerRegistry.buildJaitMcpServerRef(config, getRequestBaseUrl(request), {
915
+ sessionId,
916
+ workspaceRoot: cliWsRoot,
917
+ })];
918
+ // ── Reuse an existing CLI session if one is alive for this Jait session ──
919
+ const cachedCliSession = activeCliSessions.get(sessionId);
920
+ let providerSessionId;
921
+ if (cachedCliSession && cachedCliSession.providerId === requestProvider && cachedCliSession.runtimeMode === runtimeMode) {
922
+ // Existing session with the same provider — try to reuse it
923
+ providerSessionId = cachedCliSession.providerSessionId;
924
+ cliProvider = cachedCliSession.provider;
925
+ console.log(`[chat/cli] Reusing ${requestProvider}/${runtimeMode} session ${providerSessionId} for ${sessionId}`);
977
926
  }
978
- };
979
- const unsubscribe = cliProvider.onEvent((event) => {
980
- if (event.sessionId !== providerSessionId) {
981
- return;
982
- }
983
- // Map provider events to SSE events the frontend understands
984
- switch (event.type) {
985
- case "token":
986
- // If there's a pending tool group, flush it first
987
- flushToolGroup();
988
- contentChunks.push(event.content);
989
- tokenBytesThisBlock += event.content.length;
990
- lastSegmentWasText = false; // new text arrived
991
- accumulateToken(sessionId, event.content);
992
- safeWrite(`data: ${JSON.stringify({ type: "token", content: event.content })}\n\n`);
993
- emitToSubscribers(sessionId, { type: "token", content: event.content });
994
- break;
995
- case "tool.start": {
996
- // Reset token counter — any subsequent message event for a NEW
997
- // agent response (after tools run) should be emitted if no
998
- // tokens were streamed for it.
999
- tokenBytesThisBlock = 0;
1000
- const callId = event.callId ?? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1001
- // Accumulate for persistence
1002
- cliToolCalls.push({
1003
- callId,
1004
- tool: event.tool,
1005
- args: event.args ?? {},
1006
- ok: true,
1007
- message: "",
1008
- startedAt: Date.now(),
1009
- });
1010
- pendingToolGroup.push(callId);
1011
- // Save backup of the original file before external providers mutate it.
1012
- const mutationPath = getExternalFileMutationPath(event.tool, event.args);
1013
- if (mutationPath && cliFsSurface) {
1014
- cliFsSurface.saveExternalBackup(mutationPath).catch(() => { });
927
+ else {
928
+ // If the user switched providers, stop the old session first
929
+ if (cachedCliSession) {
930
+ try {
931
+ await cachedCliSession.provider.stopSession(cachedCliSession.providerSessionId);
1015
932
  }
1016
- safeWrite(`data: ${JSON.stringify({ type: "tool_start", call_id: callId, tool: event.tool, args: event.args })}\n\n`);
1017
- emitToSubscribers(sessionId, { type: "tool_start", call_id: callId, tool: event.tool, args: event.args });
1018
- accumulateToolStart(sessionId, callId, event.tool, event.args ?? {});
1019
- break;
933
+ catch { /* best effort */ }
934
+ activeCliSessions.delete(sessionId);
1020
935
  }
1021
- case "tool.output": {
1022
- // Accumulate streaming output on the matching tool call
1023
- const tc = cliToolCalls.find(t => t.callId === event.callId);
1024
- if (tc) {
1025
- tc.message = (tc.message || "") + event.content;
1026
- }
1027
- accumulateToolOutput(sessionId, event.callId ?? "", event.content);
1028
- safeWrite(`data: ${JSON.stringify({ type: "tool_output", call_id: event.callId, content: event.content })}\n\n`);
1029
- break;
936
+ const session = await cliProvider.startSession({
937
+ threadId: sessionId,
938
+ workingDirectory: cliWsRoot,
939
+ mode: runtimeMode,
940
+ model: typeof body["model"] === "string" ? body["model"] : undefined,
941
+ mcpServers,
942
+ });
943
+ providerSessionId = session.id;
944
+ activeCliSessions.set(sessionId, { providerId: requestProvider, runtimeMode, providerSessionId, provider: cliProvider });
945
+ console.log(`[chat/cli] Started new ${requestProvider}/${runtimeMode}${isRemote ? " (remote)" : ""} session ${providerSessionId} for ${sessionId}`);
946
+ }
947
+ // Tell the frontend about the execution context (node, workspace)
948
+ safeWrite(`data: ${JSON.stringify({
949
+ type: "session_info",
950
+ provider: requestProvider,
951
+ workspacePath: cliWsRoot,
952
+ isRemote,
953
+ ...(remoteNodeInfo ? { remoteNode: remoteNodeInfo } : {}),
954
+ })}\n\n`);
955
+ // Collect full content from CLI provider events
956
+ const contentChunks = [];
957
+ /** Bytes received via streaming `token` events for the current message block.
958
+ * Reset when a tool starts so we can correctly detect the next message block. */
959
+ let tokenBytesThisBlock = 0;
960
+ // ── Accumulate tool calls + segments for persistence ──
961
+ const cliToolCalls = [];
962
+ const cliSegments = [];
963
+ /** Track the current pending tool-group callIds (batched between text tokens) */
964
+ let pendingToolGroup = [];
965
+ let lastSegmentWasText = false;
966
+ /** Flush any buffered text into a text segment */
967
+ const flushTextSegment = () => {
968
+ if (lastSegmentWasText)
969
+ return; // already flushed
970
+ const text = contentChunks.join("");
971
+ // Only create a segment if there's new text since the last tool group
972
+ const prevTextLen = cliSegments
973
+ .filter((s) => s.type === "text")
974
+ .reduce((n, s) => n + s.content.length, 0);
975
+ const newText = text.slice(prevTextLen);
976
+ if (newText) {
977
+ cliSegments.push({ type: "text", content: newText });
1030
978
  }
1031
- case "tool.result": {
1032
- const resultCallId = event.callId ?? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1033
- // Update the matching tool call record
1034
- const tc = cliToolCalls.find(t => t.callId === resultCallId);
1035
- if (tc) {
1036
- tc.ok = event.ok;
1037
- tc.message = event.message || tc.message;
1038
- tc.data = event.data;
1039
- tc.completedAt = Date.now();
979
+ lastSegmentWasText = true;
980
+ };
981
+ /** Flush any pending tool group into a segment */
982
+ const flushToolGroup = () => {
983
+ if (pendingToolGroup.length > 0) {
984
+ // Before adding a tool group, flush any preceding text
985
+ flushTextSegment();
986
+ cliSegments.push({ type: "toolGroup", callIds: [...pendingToolGroup] });
987
+ pendingToolGroup = [];
988
+ lastSegmentWasText = false;
989
+ }
990
+ };
991
+ const unsubscribe = cliProvider.onEvent((event) => {
992
+ if (event.sessionId !== providerSessionId) {
993
+ return;
994
+ }
995
+ // Map provider events to SSE events the frontend understands
996
+ switch (event.type) {
997
+ case "token":
998
+ // If there's a pending tool group, flush it first
999
+ flushToolGroup();
1000
+ contentChunks.push(event.content);
1001
+ tokenBytesThisBlock += event.content.length;
1002
+ lastSegmentWasText = false; // new text arrived
1003
+ accumulateToken(sessionId, event.content);
1004
+ safeWrite(`data: ${JSON.stringify({ type: "token", content: event.content })}\n\n`);
1005
+ emitToSubscribers(sessionId, { type: "token", content: event.content });
1006
+ break;
1007
+ case "tool.start": {
1008
+ // Reset token counter — any subsequent message event for a NEW
1009
+ // agent response (after tools run) should be emitted if no
1010
+ // tokens were streamed for it.
1011
+ tokenBytesThisBlock = 0;
1012
+ const callId = event.callId ?? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1013
+ // Accumulate for persistence
1014
+ cliToolCalls.push({
1015
+ callId,
1016
+ tool: event.tool,
1017
+ args: event.args ?? {},
1018
+ ok: true,
1019
+ message: "",
1020
+ startedAt: Date.now(),
1021
+ });
1022
+ pendingToolGroup.push(callId);
1023
+ // Save backup of the original file before external providers mutate it.
1024
+ const mutationPath = getExternalFileMutationPath(event.tool, event.args);
1025
+ if (mutationPath && cliFsSurface) {
1026
+ cliFsSurface.saveExternalBackup(mutationPath).catch(() => { });
1027
+ }
1028
+ safeWrite(`data: ${JSON.stringify({ type: "tool_start", call_id: callId, tool: event.tool, args: event.args })}\n\n`);
1029
+ emitToSubscribers(sessionId, { type: "tool_start", call_id: callId, tool: event.tool, args: event.args });
1030
+ accumulateToolStart(sessionId, callId, event.tool, event.args ?? {});
1031
+ break;
1040
1032
  }
1041
- safeWrite(`data: ${JSON.stringify({ type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message, data: event.data })}\n\n`);
1042
- emitToSubscribers(sessionId, { type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message, data: event.data });
1043
- accumulateToolResult(sessionId, resultCallId, event.ok, event.message || "", event.data);
1044
- // Emit todo_list event for TodoWrite (normalized to "todo") tool calls
1045
- if (event.ok && (tc?.tool === "todo" || event.tool === "todo")) {
1046
- const rawTodos = tc?.args?.["todos"];
1047
- if (Array.isArray(rawTodos)) {
1048
- const items = rawTodos.map((t, i) => ({
1049
- id: typeof t["id"] === "number" ? t["id"] : i,
1050
- title: String(t["content"] ?? t["title"] ?? ""),
1051
- status: t["status"] === "in_progress" ? "in-progress" : t["status"] === "completed" ? "completed" : "not-started",
1052
- }));
1053
- const todoListEvent = { type: "todo_list", items };
1054
- safeWrite(`data: ${JSON.stringify(todoListEvent)}\n\n`);
1055
- emitToSubscribers(sessionId, todoListEvent);
1056
- if (sessionStateService) {
1057
- try {
1058
- sessionStateService.set(sessionId, { "todo_list": items });
1033
+ case "tool.output": {
1034
+ // Accumulate streaming output on the matching tool call
1035
+ const tc = cliToolCalls.find(t => t.callId === event.callId);
1036
+ if (tc) {
1037
+ tc.message = (tc.message || "") + event.content;
1038
+ }
1039
+ accumulateToolOutput(sessionId, event.callId ?? "", event.content);
1040
+ safeWrite(`data: ${JSON.stringify({ type: "tool_output", call_id: event.callId, content: event.content })}\n\n`);
1041
+ break;
1042
+ }
1043
+ case "tool.result": {
1044
+ const resultCallId = event.callId ?? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1045
+ // Update the matching tool call record
1046
+ const tc = cliToolCalls.find(t => t.callId === resultCallId);
1047
+ if (tc) {
1048
+ tc.ok = event.ok;
1049
+ tc.message = event.message || tc.message;
1050
+ tc.data = event.data;
1051
+ tc.completedAt = Date.now();
1052
+ }
1053
+ safeWrite(`data: ${JSON.stringify({ type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message, data: event.data })}\n\n`);
1054
+ emitToSubscribers(sessionId, { type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message, data: event.data });
1055
+ accumulateToolResult(sessionId, resultCallId, event.ok, event.message || "", event.data);
1056
+ // Emit todo_list event for TodoWrite (normalized to "todo") tool calls
1057
+ if (event.ok && (tc?.tool === "todo" || event.tool === "todo")) {
1058
+ const rawTodos = tc?.args?.["todos"];
1059
+ if (Array.isArray(rawTodos)) {
1060
+ const items = rawTodos.map((t, i) => ({
1061
+ id: typeof t["id"] === "number" ? t["id"] : i,
1062
+ title: String(t["content"] ?? t["title"] ?? ""),
1063
+ status: t["status"] === "in_progress" ? "in-progress" : t["status"] === "completed" ? "completed" : "not-started",
1064
+ }));
1065
+ const todoListEvent = { type: "todo_list", items };
1066
+ safeWrite(`data: ${JSON.stringify(todoListEvent)}\n\n`);
1067
+ emitToSubscribers(sessionId, todoListEvent);
1068
+ if (sessionStateService) {
1069
+ try {
1070
+ sessionStateService.set(sessionId, { "todo_list": items });
1071
+ }
1072
+ catch { /* ignore */ }
1073
+ }
1074
+ if (ws) {
1075
+ ws.broadcast(sessionId, {
1076
+ type: "ui.state-sync",
1077
+ sessionId,
1078
+ timestamp: new Date().toISOString(),
1079
+ payload: { key: "todo_list", value: items },
1080
+ });
1059
1081
  }
1060
- catch { /* ignore */ }
1061
1082
  }
1083
+ }
1084
+ // Emit file_changed for successful external file mutations.
1085
+ const mutationPath = getExternalFileMutationPath(tc?.tool ?? event.tool, tc?.args ?? {});
1086
+ if (event.ok && mutationPath) {
1087
+ const editName = mutationPath.split(/[\/\\]/).pop() ?? mutationPath;
1088
+ safeWrite(`data: ${JSON.stringify({ type: "file_changed", path: mutationPath, name: editName })}\n\n`);
1089
+ // Broadcast to other session clients
1062
1090
  if (ws) {
1063
1091
  ws.broadcast(sessionId, {
1064
1092
  type: "ui.state-sync",
1065
1093
  sessionId,
1066
1094
  timestamp: new Date().toISOString(),
1067
- payload: { key: "todo_list", value: items },
1095
+ payload: { key: "file_changed", value: { path: mutationPath, name: editName } },
1068
1096
  });
1069
1097
  }
1070
- }
1071
- }
1072
- // Emit file_changed for successful external file mutations.
1073
- const mutationPath = getExternalFileMutationPath(tc?.tool ?? event.tool, tc?.args ?? {});
1074
- if (event.ok && mutationPath) {
1075
- const editName = mutationPath.split(/[\/\\]/).pop() ?? mutationPath;
1076
- safeWrite(`data: ${JSON.stringify({ type: "file_changed", path: mutationPath, name: editName })}\n\n`);
1077
- // Broadcast to other session clients
1078
- if (ws) {
1079
- ws.broadcast(sessionId, {
1080
- type: "ui.state-sync",
1081
- sessionId,
1082
- timestamp: new Date().toISOString(),
1083
- payload: { key: "file_changed", value: { path: mutationPath, name: editName } },
1084
- });
1085
- }
1086
- // Persist cumulative changed files list
1087
- if (sessionStateService) {
1088
- try {
1089
- const existing = sessionStateService.get(sessionId, ["changed_files"]);
1090
- const files = Array.isArray(existing["changed_files"]) ? existing["changed_files"] : [];
1091
- if (!files.some((f) => f.path === mutationPath)) {
1092
- files.push({ path: mutationPath, name: editName });
1093
- sessionStateService.set(sessionId, { changed_files: files });
1098
+ // Persist cumulative changed files list
1099
+ if (sessionStateService) {
1100
+ try {
1101
+ const existing = sessionStateService.get(sessionId, ["changed_files"]);
1102
+ const files = Array.isArray(existing["changed_files"]) ? existing["changed_files"] : [];
1103
+ if (!files.some((f) => f.path === mutationPath)) {
1104
+ files.push({ path: mutationPath, name: editName });
1105
+ sessionStateService.set(sessionId, { changed_files: files });
1106
+ }
1094
1107
  }
1108
+ catch { /* ignore */ }
1095
1109
  }
1096
- catch { /* ignore */ }
1097
1110
  }
1111
+ break;
1098
1112
  }
1099
- break;
1100
- }
1101
- case "tool.approval-required":
1102
- safeWrite(`data: ${JSON.stringify({ type: "approval_required", tool: event.tool, args: event.args, requestId: event.requestId })}\n\n`);
1103
- break;
1104
- case "message":
1105
- if (event.role === "assistant" && event.content) {
1106
- flushToolGroup();
1107
- // `message` events carry the *complete* text of an agent message.
1108
- // If token deltas already streamed this content, skip to avoid
1109
- // doubling the persisted text. Only use as fallback when Codex
1110
- // sends a complete message without preceding token deltas.
1111
- if (tokenBytesThisBlock === 0) {
1112
- contentChunks.push(event.content);
1113
- safeWrite(`data: ${JSON.stringify({ type: "token", content: event.content })}\n\n`);
1114
- emitToSubscribers(sessionId, { type: "token", content: event.content });
1113
+ case "tool.approval-required":
1114
+ safeWrite(`data: ${JSON.stringify({ type: "approval_required", tool: event.tool, args: event.args, requestId: event.requestId })}\n\n`);
1115
+ break;
1116
+ case "message":
1117
+ if (event.role === "assistant" && event.content) {
1118
+ flushToolGroup();
1119
+ // `message` events carry the *complete* text of an agent message.
1120
+ // If token deltas already streamed this content, skip to avoid
1121
+ // doubling the persisted text. Only use as fallback when Codex
1122
+ // sends a complete message without preceding token deltas.
1123
+ if (tokenBytesThisBlock === 0) {
1124
+ contentChunks.push(event.content);
1125
+ safeWrite(`data: ${JSON.stringify({ type: "token", content: event.content })}\n\n`);
1126
+ emitToSubscribers(sessionId, { type: "token", content: event.content });
1127
+ }
1128
+ lastSegmentWasText = false;
1115
1129
  }
1116
- lastSegmentWasText = false;
1117
- }
1118
- break;
1119
- case "session.error":
1120
- safeWrite(`data: ${JSON.stringify({ type: "error", message: event.error })}\n\n`);
1121
- break;
1122
- }
1123
- });
1124
- // Send the turn — with recovery if the cached session died between messages
1125
- try {
1126
- await cliProvider.sendTurn(providerSessionId, content);
1127
- }
1128
- catch (sendErr) {
1129
- // Session likely died (process exited) — start a fresh one
1130
- console.warn(`[chat/cli] sendTurn failed on cached session, recovering:`, sendErr);
1131
- activeCliSessions.delete(sessionId);
1132
- const freshSession = await cliProvider.startSession({
1133
- threadId: sessionId,
1134
- workingDirectory: cliWsRoot,
1135
- mode: runtimeMode,
1136
- model: typeof body["model"] === "string" ? body["model"] : undefined,
1137
- mcpServers,
1138
- });
1139
- providerSessionId = freshSession.id;
1140
- activeCliSessions.set(sessionId, { providerId: requestProvider, runtimeMode, providerSessionId, provider: cliProvider });
1141
- console.log(`[chat/cli] Recovered with new ${requestProvider}/${runtimeMode} session ${providerSessionId}`);
1142
- await cliProvider.sendTurn(providerSessionId, content);
1143
- }
1144
- // Wait for turn completion or error
1145
- await new Promise((resolve) => {
1146
- const checkDone = cliProvider.onEvent((event) => {
1147
- if (event.sessionId !== providerSessionId) {
1148
- return;
1130
+ break;
1131
+ case "session.error":
1132
+ safeWrite(`data: ${JSON.stringify({ type: "error", message: event.error })}\n\n`);
1133
+ break;
1149
1134
  }
1150
- if (event.type === "session.completed" || event.type === "session.error" || event.type === "turn.completed") {
1151
- // If the session errored, invalidate the cache so the next message creates a fresh one
1152
- if (event.type === "session.error") {
1153
- activeCliSessions.delete(sessionId);
1135
+ });
1136
+ // Send the turn with recovery if the cached session died between messages
1137
+ try {
1138
+ await cliProvider.sendTurn(providerSessionId, content);
1139
+ }
1140
+ catch (sendErr) {
1141
+ // Session likely died (process exited) — start a fresh one
1142
+ console.warn(`[chat/cli] sendTurn failed on cached session, recovering:`, sendErr);
1143
+ activeCliSessions.delete(sessionId);
1144
+ const freshSession = await cliProvider.startSession({
1145
+ threadId: sessionId,
1146
+ workingDirectory: cliWsRoot,
1147
+ mode: runtimeMode,
1148
+ model: typeof body["model"] === "string" ? body["model"] : undefined,
1149
+ mcpServers,
1150
+ });
1151
+ providerSessionId = freshSession.id;
1152
+ activeCliSessions.set(sessionId, { providerId: requestProvider, runtimeMode, providerSessionId, provider: cliProvider });
1153
+ console.log(`[chat/cli] Recovered with new ${requestProvider}/${runtimeMode} session ${providerSessionId}`);
1154
+ await cliProvider.sendTurn(providerSessionId, content);
1155
+ }
1156
+ // Wait for turn completion or error
1157
+ await new Promise((resolve) => {
1158
+ const checkDone = cliProvider.onEvent((event) => {
1159
+ if (event.sessionId !== providerSessionId) {
1160
+ return;
1154
1161
  }
1155
- checkDone();
1162
+ if (event.type === "session.completed" || event.type === "session.error" || event.type === "turn.completed") {
1163
+ // If the session errored, invalidate the cache so the next message creates a fresh one
1164
+ if (event.type === "session.error") {
1165
+ activeCliSessions.delete(sessionId);
1166
+ }
1167
+ checkDone();
1168
+ resolve();
1169
+ }
1170
+ });
1171
+ // Also abort if client disconnects
1172
+ streamAbort.signal.addEventListener("abort", () => {
1173
+ cliProvider.interruptTurn(providerSessionId).catch(() => { });
1156
1174
  resolve();
1157
- }
1175
+ });
1158
1176
  });
1159
- // Also abort if client disconnects
1160
- streamAbort.signal.addEventListener("abort", () => {
1161
- cliProvider.interruptTurn(providerSessionId).catch(() => { });
1162
- resolve();
1163
- });
1164
- });
1165
- unsubscribe();
1166
- fullContent = contentChunks.join("");
1167
- // Flush any remaining tool group / trailing text into segments
1168
- flushToolGroup();
1169
- flushTextSegment();
1170
- // Build persistence JSON
1171
- const cliTcJson = cliToolCalls.length > 0 ? JSON.stringify(cliToolCalls) : undefined;
1172
- const cliSegJson = cliSegments.length > 0 ? JSON.stringify(cliSegments) : undefined;
1173
- // Also stash on the outer scope so the done handler can emit them
1174
- partialToolCalls = cliToolCalls;
1175
- resultSegmentsJson = cliSegJson;
1176
- // Persist assistant message with tool calls and segments
1177
- history.push({ role: "assistant", content: fullContent, uiToolCalls: cliToolCalls.length > 0 ? cliToolCalls : undefined });
1178
- persistMessage(sessionId, "assistant", fullContent, cliTcJson, cliSegJson);
1179
- // Session stays alive for the next turn — do NOT stop it.
1180
- // It will be cleaned up on session error, provider switch, or server shutdown.
1177
+ unsubscribe();
1178
+ fullContent = contentChunks.join("");
1179
+ // Flush any remaining tool group / trailing text into segments
1180
+ flushToolGroup();
1181
+ flushTextSegment();
1182
+ // Build persistence JSON
1183
+ const cliTcJson = cliToolCalls.length > 0 ? JSON.stringify(cliToolCalls) : undefined;
1184
+ const cliSegJson = cliSegments.length > 0 ? JSON.stringify(cliSegments) : undefined;
1185
+ // Also stash on the outer scope so the done handler can emit them
1186
+ partialToolCalls = cliToolCalls;
1187
+ resultSegmentsJson = cliSegJson;
1188
+ // Persist assistant message with tool calls and segments
1189
+ history.push({ role: "assistant", content: fullContent, uiToolCalls: cliToolCalls.length > 0 ? cliToolCalls : undefined });
1190
+ persistMessage(sessionId, "assistant", fullContent, cliTcJson, cliSegJson);
1191
+ // Session stays alive for the next turn do NOT stop it.
1192
+ // It will be cleaned up on session error, provider switch, or server shutdown.
1193
+ usedCliProvider = true;
1194
+ } // end availability else
1181
1195
  }
1182
- else if (config.llmProvider === "openai") {
1196
+ if (!usedCliProvider && config.llmProvider === "openai") {
1183
1197
  // ══ OpenAI agentic loop (using extracted runAgentLoop) ═════
1184
1198
  // Build tiered schemas per request — respects user-disabled tools
1185
1199
  const userSettings = userService?.getSettings(authUser.id);