@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.
- package/dist/providers/jait-provider.d.ts +2 -0
- package/dist/providers/jait-provider.d.ts.map +1 -1
- package/dist/providers/jait-provider.js +16 -0
- package/dist/providers/jait-provider.js.map +1 -1
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +52 -12
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/chat.d.ts.map +1 -1
- package/dist/routes/chat.js +302 -288
- package/dist/routes/chat.js.map +1 -1
- package/dist/routes/threads.d.ts.map +1 -1
- package/dist/routes/threads.js +13 -1
- package/dist/routes/threads.js.map +1 -1
- package/dist/security/http-auth.d.ts +1 -0
- package/dist/security/http-auth.d.ts.map +1 -1
- package/dist/security/http-auth.js +3 -1
- package/dist/security/http-auth.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/surfaces/terminal.d.ts.map +1 -1
- package/dist/surfaces/terminal.js +1 -0
- package/dist/surfaces/terminal.js.map +1 -1
- package/dist/ws.d.ts +1 -0
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +9 -1
- package/dist/ws.js.map +1 -1
- package/package.json +3 -3
- package/web-dist/assets/{_basePickBy-5TWDl4JM.js → _basePickBy-Y4-sJhMJ.js} +1 -1
- package/web-dist/assets/{_baseUniq-CMQOESlr.js → _baseUniq-BFWigmYH.js} +1 -1
- package/web-dist/assets/{arc-M3t7zaj1.js → arc-DItQgg97.js} +1 -1
- package/web-dist/assets/{architectureDiagram-2XIMDMQ5-c9m8SS0c.js → architectureDiagram-2XIMDMQ5-CGgl58gT.js} +1 -1
- package/web-dist/assets/{blockDiagram-WCTKOSBZ-DqUqNIzU.js → blockDiagram-WCTKOSBZ-D-WlS5xD.js} +1 -1
- package/web-dist/assets/{c4Diagram-IC4MRINW-CNzQ33JK.js → c4Diagram-IC4MRINW-CdG8FSdv.js} +1 -1
- package/web-dist/assets/channel-Cyxt55lU.js +1 -0
- package/web-dist/assets/{chunk-4BX2VUAB-COuOVu1p.js → chunk-4BX2VUAB-BL-i6cr8.js} +1 -1
- package/web-dist/assets/{chunk-55IACEB6-CDTARcKO.js → chunk-55IACEB6-D-WoPZ_Q.js} +1 -1
- package/web-dist/assets/{chunk-FMBD7UC4-BF2uRoZ2.js → chunk-FMBD7UC4-DLwJx6mv.js} +1 -1
- package/web-dist/assets/{chunk-JSJVCQXG-DbnYPeoT.js → chunk-JSJVCQXG-H-QtRq-_.js} +1 -1
- package/web-dist/assets/{chunk-KX2RTZJC-Tim2NTNu.js → chunk-KX2RTZJC-0OIICzqR.js} +1 -1
- package/web-dist/assets/{chunk-NQ4KR5QH-ZvRCCrAp.js → chunk-NQ4KR5QH-BFrfoApj.js} +1 -1
- package/web-dist/assets/{chunk-QZHKN3VN-nA0PVVcm.js → chunk-QZHKN3VN-DhOr0Y5J.js} +1 -1
- package/web-dist/assets/{chunk-WL4C6EOR-B9-vmYuf.js → chunk-WL4C6EOR-BER0qM1r.js} +1 -1
- package/web-dist/assets/classDiagram-VBA2DB6C-YPDYVFoc.js +1 -0
- package/web-dist/assets/classDiagram-v2-RAHNMMFH-YPDYVFoc.js +1 -0
- package/web-dist/assets/clone-lVvDwdQf.js +1 -0
- package/web-dist/assets/{cose-bilkent-S5V4N54A-B7pE6AKK.js → cose-bilkent-S5V4N54A-BKpddDxN.js} +1 -1
- package/web-dist/assets/{dagre-KLK3FWXG-B-PVXqSH.js → dagre-KLK3FWXG-Ny_S7TzO.js} +1 -1
- package/web-dist/assets/{diagram-E7M64L7V-QEmBAviP.js → diagram-E7M64L7V-DnwGN2nP.js} +1 -1
- package/web-dist/assets/{diagram-IFDJBPK2-mEsLVcNK.js → diagram-IFDJBPK2-D61YyDHG.js} +1 -1
- package/web-dist/assets/{diagram-P4PSJMXO-Da2wOIVw.js → diagram-P4PSJMXO-CPAi1bMg.js} +1 -1
- package/web-dist/assets/{erDiagram-INFDFZHY-Dqhl3IbI.js → erDiagram-INFDFZHY-BW_3YxkM.js} +1 -1
- package/web-dist/assets/{flowDiagram-PKNHOUZH-B3xwnDF3.js → flowDiagram-PKNHOUZH-D4fIoGDZ.js} +1 -1
- package/web-dist/assets/{ganttDiagram-A5KZAMGK-CfCW_MyX.js → ganttDiagram-A5KZAMGK-Bgejn6fJ.js} +1 -1
- package/web-dist/assets/{gitGraphDiagram-K3NZZRJ6-Rf_I-a2c.js → gitGraphDiagram-K3NZZRJ6-D_hGBsYv.js} +1 -1
- package/web-dist/assets/{graph-lq7-soOy.js → graph-Cr6kCiTF.js} +1 -1
- package/web-dist/assets/{index-CvzAVoMc.js → index-Bb-icMtw.js} +320 -320
- package/web-dist/assets/index-COvsYl9M.css +32 -0
- package/web-dist/assets/{infoDiagram-LFFYTUFH-DdNDArV9.js → infoDiagram-LFFYTUFH-DJr3cor-.js} +1 -1
- package/web-dist/assets/{ishikawaDiagram-PHBUUO56-CVZeeAPF.js → ishikawaDiagram-PHBUUO56-eq7b9gMG.js} +1 -1
- package/web-dist/assets/{journeyDiagram-4ABVD52K-BOChQ2Bs.js → journeyDiagram-4ABVD52K-B7G9MecY.js} +1 -1
- package/web-dist/assets/{kanban-definition-K7BYSVSG-BeiHTl71.js → kanban-definition-K7BYSVSG-GSTXE3lX.js} +1 -1
- package/web-dist/assets/{layout-CM3LVb57.js → layout-BujL5zIm.js} +1 -1
- package/web-dist/assets/{linear-CzlIHkfY.js → linear-C6WRDsJX.js} +1 -1
- package/web-dist/assets/{mindmap-definition-YRQLILUH-dsoQ6qZM.js → mindmap-definition-YRQLILUH-zeolX9nH.js} +1 -1
- package/web-dist/assets/{pieDiagram-SKSYHLDU-B2YujYSK.js → pieDiagram-SKSYHLDU-BkDkyYLU.js} +1 -1
- package/web-dist/assets/{quadrantDiagram-337W2JSQ-CfRx6Vxe.js → quadrantDiagram-337W2JSQ-D2ZadOZg.js} +1 -1
- package/web-dist/assets/{requirementDiagram-Z7DCOOCP-CwxbIP8g.js → requirementDiagram-Z7DCOOCP-C3bismXb.js} +1 -1
- package/web-dist/assets/{sankeyDiagram-WA2Y5GQK-RTlZrggt.js → sankeyDiagram-WA2Y5GQK-HGrQ6VhU.js} +1 -1
- package/web-dist/assets/{sequenceDiagram-2WXFIKYE-C6ExE-CT.js → sequenceDiagram-2WXFIKYE-BCAUdUtW.js} +1 -1
- package/web-dist/assets/{stateDiagram-RAJIS63D-yIq6UCoM.js → stateDiagram-RAJIS63D-CQqZ1lYn.js} +1 -1
- package/web-dist/assets/stateDiagram-v2-FVOUBMTO-CJXglv7T.js +1 -0
- package/web-dist/assets/{timeline-definition-YZTLITO2-BXlhV5HG.js → timeline-definition-YZTLITO2-BE5v6UP8.js} +1 -1
- package/web-dist/assets/{treemap-KZPCXAKY-DLKOCiK9.js → treemap-KZPCXAKY-D5fESSPe.js} +1 -1
- package/web-dist/assets/{vennDiagram-LZ73GAT5-DNrWcYRu.js → vennDiagram-LZ73GAT5-5M80z6e-.js} +1 -1
- package/web-dist/assets/{xychartDiagram-JWTSCODW-CniBBtHN.js → xychartDiagram-JWTSCODW-3ZvKqnud.js} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/assets/channel-8ylz1LZN.js +0 -1
- package/web-dist/assets/classDiagram-VBA2DB6C-CVI5svHi.js +0 -1
- package/web-dist/assets/classDiagram-v2-RAHNMMFH-CVI5svHi.js +0 -1
- package/web-dist/assets/clone-CF_hu4RI.js +0 -1
- package/web-dist/assets/index-BMzEl1co.css +0 -32
- package/web-dist/assets/stateDiagram-v2-FVOUBMTO-WZobr7ge.js +0 -1
package/dist/routes/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
723
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
if
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
accumulateToolStart(sessionId, callId, event.tool, event.args ?? {});
|
|
1019
|
-
break;
|
|
933
|
+
catch { /* best effort */ }
|
|
934
|
+
activeCliSessions.delete(sessionId);
|
|
1020
935
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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: "
|
|
1095
|
+
payload: { key: "file_changed", value: { path: mutationPath, name: editName } },
|
|
1068
1096
|
});
|
|
1069
1097
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
//
|
|
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
|
-
|
|
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);
|