opencandle 0.7.0 → 0.9.0
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/README.md +34 -21
- package/dist/cli-main.d.ts +1 -0
- package/dist/cli-main.js +224 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +13 -229
- package/dist/cli.js.map +1 -1
- package/dist/doctor/cli-command.d.ts +1 -0
- package/dist/doctor/cli-command.js +69 -0
- package/dist/doctor/cli-command.js.map +1 -0
- package/dist/doctor/render.d.ts +2 -0
- package/dist/doctor/render.js +32 -0
- package/dist/doctor/render.js.map +1 -0
- package/dist/doctor/report.d.ts +63 -0
- package/dist/doctor/report.js +500 -0
- package/dist/doctor/report.js.map +1 -0
- package/dist/infra/native-dependencies.d.ts +8 -0
- package/dist/infra/native-dependencies.js +49 -0
- package/dist/infra/native-dependencies.js.map +1 -1
- package/dist/infra/node-version.js +2 -5
- package/dist/infra/node-version.js.map +1 -1
- package/dist/monitor.d.ts +1 -1
- package/dist/monitor.js +2 -1
- package/dist/monitor.js.map +1 -1
- package/dist/onboarding/provider-status.d.ts +3 -5
- package/dist/onboarding/provider-status.js +5 -34
- package/dist/onboarding/provider-status.js.map +1 -1
- package/dist/pi/session-writer-lock.d.ts +30 -0
- package/dist/pi/session-writer-lock.js +111 -0
- package/dist/pi/session-writer-lock.js.map +1 -0
- package/dist/pi/session.d.ts +0 -1
- package/dist/pi/session.js +2 -1
- package/dist/pi/session.js.map +1 -1
- package/dist/prompts/policy-cards.js +8 -4
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/prompts/workflow-prompts.js +25 -6
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/external-tool-command.d.ts +17 -0
- package/dist/providers/external-tool-command.js +114 -0
- package/dist/providers/external-tool-command.js.map +1 -0
- package/dist/providers/reddit-cli.d.ts +2 -6
- package/dist/providers/reddit-cli.js +4 -33
- package/dist/providers/reddit-cli.js.map +1 -1
- package/dist/providers/twitter-cli.d.ts +2 -6
- package/dist/providers/twitter-cli.js +4 -33
- package/dist/providers/twitter-cli.js.map +1 -1
- package/dist/routing/classify-intent.js +15 -12
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/entity-extractor.js +1 -1
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/planning.js +9 -6
- package/dist/routing/planning.js.map +1 -1
- package/dist/routing/router-llm-client.d.ts +1 -1
- package/dist/routing/router-llm-client.js +1 -1
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router.js +44 -3
- package/dist/routing/router.js.map +1 -1
- package/dist/workflows/compare-assets.js +4 -1
- package/dist/workflows/compare-assets.js.map +1 -1
- package/gui/server/ask-user-bridge.ts +26 -19
- package/gui/server/chat-event-adapter.ts +49 -3
- package/gui/server/http-routes.ts +577 -0
- package/gui/server/invoke-tool.ts +102 -7
- package/gui/server/live-chat-event-adapter.ts +16 -4
- package/gui/server/server.ts +70 -296
- package/gui/server/session-actions.ts +7 -1
- package/gui/server/session-entry-wait.ts +79 -0
- package/gui/server/writer-lock.ts +1 -122
- package/gui/server/ws-hub.ts +25 -0
- package/gui/shared/chat-events.ts +42 -19
- package/gui/shared/event-reducer.ts +41 -26
- package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +1 -0
- package/gui/web/dist/assets/index-DdMnIvZ7.css +2 -0
- package/gui/web/dist/assets/index-YlMaahAS.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +23 -19
- package/src/cli-main.ts +270 -0
- package/src/cli.ts +14 -267
- package/src/doctor/cli-command.ts +83 -0
- package/src/doctor/render.ts +37 -0
- package/src/doctor/report.ts +638 -0
- package/src/infra/native-dependencies.ts +67 -0
- package/src/infra/node-version.ts +2 -5
- package/src/monitor.ts +3 -1
- package/src/onboarding/provider-status.ts +10 -38
- package/src/pi/session-writer-lock.ts +156 -0
- package/src/pi/session.ts +2 -1
- package/src/prompts/policy-cards.ts +12 -4
- package/src/prompts/workflow-prompts.ts +28 -6
- package/src/providers/external-tool-command.ts +164 -0
- package/src/providers/reddit-cli.ts +10 -41
- package/src/providers/twitter-cli.ts +10 -41
- package/src/routing/classify-intent.ts +22 -17
- package/src/routing/entity-extractor.ts +1 -1
- package/src/routing/planning.ts +15 -10
- package/src/routing/router-llm-client.ts +2 -1
- package/src/routing/router.ts +57 -6
- package/src/workflows/compare-assets.ts +4 -2
- package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +0 -1
- package/gui/web/dist/assets/index-C6W_2eAn.js +0 -69
- package/gui/web/dist/assets/index-hwbx24a5.css +0 -1
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { extname, join, resolve } from "node:path";
|
|
4
|
+
import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { buildDoctorReport } from "../../src/doctor/report.js";
|
|
6
|
+
import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
|
|
7
|
+
import type { ChatEvent } from "../shared/chat-events.js";
|
|
8
|
+
import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
|
|
9
|
+
import { chatRunSessionConflict } from "./chat-run-session.js";
|
|
10
|
+
import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
|
|
11
|
+
import { buildMarketStateSnapshot, searchInstrumentCandidates } from "./market-state-api.js";
|
|
12
|
+
import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
|
|
13
|
+
import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
|
|
14
|
+
import { projectDashboard } from "./projector.js";
|
|
15
|
+
import { createPromptObservation, observePromptEvent } from "./prompt-observation.js";
|
|
16
|
+
import type { QuoteSnapshotStore } from "./quote-snapshot-store.js";
|
|
17
|
+
import { promptAndSettle, type SessionActionsController } from "./session-actions.js";
|
|
18
|
+
import { waitForNewEntryId } from "./session-entry-wait.js";
|
|
19
|
+
import { buildCatalog } from "./tool-metadata.js";
|
|
20
|
+
import {
|
|
21
|
+
acquireWriterLock,
|
|
22
|
+
readWriterLock,
|
|
23
|
+
refreshWriterLock,
|
|
24
|
+
releaseWriterLock,
|
|
25
|
+
writerLockScopeForSession,
|
|
26
|
+
} from "./writer-lock.js";
|
|
27
|
+
import type { WsHub } from "./ws-hub.js";
|
|
28
|
+
|
|
29
|
+
interface GuiHttpRouteOptions {
|
|
30
|
+
host: string;
|
|
31
|
+
port: number;
|
|
32
|
+
webDist: string;
|
|
33
|
+
role: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
agentDir: string;
|
|
36
|
+
sessionDir: string;
|
|
37
|
+
privateApiSessionToken: string;
|
|
38
|
+
allowRemotePrivateApi: boolean;
|
|
39
|
+
getSession: () => AgentSession;
|
|
40
|
+
getSessionManager: () => SessionManager;
|
|
41
|
+
createSessionForManager: (sessionManager: SessionManager) => Promise<{ session: AgentSession }>;
|
|
42
|
+
wsHub: WsHub;
|
|
43
|
+
modelSetupController: ModelSetupController;
|
|
44
|
+
sessionActionsController: SessionActionsController;
|
|
45
|
+
quoteSnapshotStore: QuoteSnapshotStore;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
49
|
+
const activeRunSessionIds = new Set<string>();
|
|
50
|
+
|
|
51
|
+
return async function handleHttpRequest(
|
|
52
|
+
req: IncomingMessage,
|
|
53
|
+
res: ServerResponse,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const url = new URL(req.url ?? "/", `http://${options.host}:${options.port}`);
|
|
56
|
+
if (url.pathname === "/health") {
|
|
57
|
+
writeJson(res, { ok: true, role: options.role });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (url.pathname === "/api/bootstrap" && req.method === "GET") {
|
|
62
|
+
if (!allowTrustedGuiRequest(req, res, "Bootstrap API", options)) return;
|
|
63
|
+
writeJson(res, await options.wsHub.buildBootstrapPayload());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (url.pathname === "/api/session/new" && req.method === "POST") {
|
|
68
|
+
if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
|
|
69
|
+
if (options.role !== "writer") {
|
|
70
|
+
writeJson(res, { error: "Read-only follower mode" }, 409);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await options.sessionActionsController.handleNewSession();
|
|
74
|
+
options.wsHub.broadcastState();
|
|
75
|
+
options.wsHub.broadcastSessions();
|
|
76
|
+
writeJson(res, await options.wsHub.buildBootstrapPayload());
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
81
|
+
if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
|
|
82
|
+
writeJson(res, {
|
|
83
|
+
currentSessionId: options.getSessionManager().getSessionId(),
|
|
84
|
+
role: options.role,
|
|
85
|
+
sessions: await SessionManager.list(options.cwd, options.sessionDir),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sessionBootstrapId = sessionIdFromRoute(url.pathname, "bootstrap");
|
|
91
|
+
if (sessionBootstrapId && req.method === "GET") {
|
|
92
|
+
if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
|
|
93
|
+
const sessionManager = await resolveSessionManagerById(options, sessionBootstrapId);
|
|
94
|
+
if (!sessionManager) {
|
|
95
|
+
writeJson(res, { error: "Unknown saved session" }, 404);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
writeJson(res, await buildSessionBootstrapPayload(options, sessionManager));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (url.pathname === "/api/session/events" && req.method === "GET") {
|
|
103
|
+
if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
|
|
104
|
+
writeJson(res, {
|
|
105
|
+
sessionId: options.getSessionManager().getSessionId(),
|
|
106
|
+
role: options.role,
|
|
107
|
+
events: options.wsHub.currentChatEvents(),
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (url.pathname === "/api/model-setup/refresh" && req.method === "POST") {
|
|
113
|
+
if (!allowTrustedGuiRequest(req, res, "Model setup API", options)) return;
|
|
114
|
+
options.getSession().modelRegistry.refresh();
|
|
115
|
+
options.wsHub.broadcastModelSetup();
|
|
116
|
+
writeJson(res, await options.wsHub.buildBootstrapPayload());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (url.pathname === "/api/model-setup/api-key" && req.method === "POST") {
|
|
121
|
+
if (!allowTrustedGuiRequest(req, res, "Model setup API", options)) return;
|
|
122
|
+
await handleTrustedGuiMutation(req, res, options, async (body) => {
|
|
123
|
+
await options.modelSetupController.handleSaveModelApiKey(
|
|
124
|
+
String(body.provider ?? ""),
|
|
125
|
+
String(body.apiKey ?? ""),
|
|
126
|
+
);
|
|
127
|
+
options.wsHub.broadcastModelSetup();
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (url.pathname === "/api/model-setup/model" && req.method === "POST") {
|
|
133
|
+
if (!allowTrustedGuiRequest(req, res, "Model setup API", options)) return;
|
|
134
|
+
await handleTrustedGuiMutation(req, res, options, async (body) => {
|
|
135
|
+
await options.modelSetupController.handleSelectModel(
|
|
136
|
+
String(body.provider ?? ""),
|
|
137
|
+
String(body.modelId ?? ""),
|
|
138
|
+
);
|
|
139
|
+
options.wsHub.broadcastModelSetup();
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (url.pathname === "/api/provider-setup/api-key" && req.method === "POST") {
|
|
145
|
+
if (!allowTrustedGuiRequest(req, res, "Provider setup API", options)) return;
|
|
146
|
+
await handleTrustedGuiMutation(req, res, options, async (body) => {
|
|
147
|
+
await options.modelSetupController.handleSaveProviderApiKey(
|
|
148
|
+
String(body.providerId ?? ""),
|
|
149
|
+
String(body.apiKey ?? ""),
|
|
150
|
+
);
|
|
151
|
+
options.wsHub.broadcast({ type: "catalog", catalog: buildCatalog() });
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (url.pathname === "/api/market-state" && req.method === "GET") {
|
|
157
|
+
if (!allowTrustedGuiRequest(req, res, "Market-state API", options)) return;
|
|
158
|
+
writeJson(res, buildMarketStateSnapshot());
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (url.pathname === "/api/market-state/quotes" && req.method === "GET") {
|
|
163
|
+
if (!allowTrustedGuiRequest(req, res, "Market-state API", options)) return;
|
|
164
|
+
writeJson(res, await options.quoteSnapshotStore.get());
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (url.pathname === "/api/doctor" && req.method === "GET") {
|
|
169
|
+
if (!allowTrustedGuiRequest(req, res, "Diagnostics API", options)) return;
|
|
170
|
+
const session = options.getSession();
|
|
171
|
+
writeJson(
|
|
172
|
+
res,
|
|
173
|
+
await buildDoctorReport({
|
|
174
|
+
cwd: options.cwd,
|
|
175
|
+
agentDir: options.agentDir,
|
|
176
|
+
includeSessions: url.searchParams.get("sessions") === "1",
|
|
177
|
+
gui: {
|
|
178
|
+
host: options.host,
|
|
179
|
+
port: options.port,
|
|
180
|
+
role: options.role,
|
|
181
|
+
reachable: true,
|
|
182
|
+
healthEndpoint: `http://${options.host}:${options.port}/health`,
|
|
183
|
+
},
|
|
184
|
+
modelSetup: buildModelSetupState(session.modelRegistry, session.model),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (url.pathname === "/api/instruments/search" && req.method === "GET") {
|
|
191
|
+
if (!allowTrustedGuiRequest(req, res, "Market-state API", options)) return;
|
|
192
|
+
writeJson(res, await searchInstrumentCandidates(url.searchParams.get("q") ?? ""));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (url.pathname === "/api/diagnostics/twitter-cli" && req.method === "GET") {
|
|
197
|
+
if (!allowTrustedGuiRequest(req, res, "Diagnostics API", options)) return;
|
|
198
|
+
const mode = url.searchParams.get("mode") === "session" ? "session" : "install";
|
|
199
|
+
const force = url.searchParams.get("force") === "1";
|
|
200
|
+
writeJson(res, await probeProviderStatus("twitter", { mode, force }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (url.pathname === "/api/diagnostics/reddit-cli" && req.method === "GET") {
|
|
205
|
+
if (!allowTrustedGuiRequest(req, res, "Diagnostics API", options)) return;
|
|
206
|
+
const mode = url.searchParams.get("mode") === "session" ? "session" : "install";
|
|
207
|
+
const force = url.searchParams.get("force") === "1";
|
|
208
|
+
writeJson(res, await probeProviderStatus("reddit", { mode, force }));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (url.pathname === "/api/chat/run" && req.method === "POST") {
|
|
213
|
+
if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
|
|
214
|
+
await handleSseChatRun(req, res, options, activeRunSessionIds);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const runSessionId = sessionIdFromRoute(url.pathname, "runs");
|
|
219
|
+
if (runSessionId && req.method === "POST") {
|
|
220
|
+
if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
|
|
221
|
+
const sessionManager = await resolveSessionManagerById(options, runSessionId);
|
|
222
|
+
if (!sessionManager) {
|
|
223
|
+
writeJson(res, { error: "Unknown saved session" }, 404);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
serveStaticAsset(url.pathname, res, options);
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function handleSseChatRun(
|
|
235
|
+
req: IncomingMessage,
|
|
236
|
+
res: ServerResponse,
|
|
237
|
+
options: GuiHttpRouteOptions,
|
|
238
|
+
activeRunSessionIds: Set<string>,
|
|
239
|
+
targetSessionManager?: SessionManager,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
if (options.role !== "writer") {
|
|
242
|
+
writeJson(res, { error: "Read-only follower mode" }, 409);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const body = await readJsonBody(req);
|
|
247
|
+
const prompt = String(asRecord(body).prompt ?? "").trim();
|
|
248
|
+
if (!prompt) {
|
|
249
|
+
writeJson(res, { error: "prompt is required" }, 400);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const currentSessionManager = options.getSessionManager();
|
|
254
|
+
const requestedSessionId = String(asRecord(body).sessionId ?? "").trim();
|
|
255
|
+
const runSessionManager = targetSessionManager ?? currentSessionManager;
|
|
256
|
+
const sessionId = runSessionManager.getSessionId();
|
|
257
|
+
if (!targetSessionManager) {
|
|
258
|
+
const sessionConflict = chatRunSessionConflict(requestedSessionId, sessionId);
|
|
259
|
+
if (sessionConflict) {
|
|
260
|
+
writeJson(res, sessionConflict, 409);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
} else if (requestedSessionId && requestedSessionId !== sessionId) {
|
|
264
|
+
writeJson(
|
|
265
|
+
res,
|
|
266
|
+
{ error: "Session route and request body disagree", code: "session_changed" },
|
|
267
|
+
409,
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (activeRunSessionIds.has(sessionId)) {
|
|
273
|
+
writeJson(res, { error: "Session already has an active run", code: "session_busy" }, 409);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const currentSessionFile = currentSessionManager.getSessionFile();
|
|
278
|
+
const targetSessionFile = runSessionManager.getSessionFile();
|
|
279
|
+
const useCurrentSession = !targetSessionManager || currentSessionFile === targetSessionFile;
|
|
280
|
+
let acquiredLockScope = "";
|
|
281
|
+
let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
282
|
+
if (!useCurrentSession) {
|
|
283
|
+
const lockScope = writerLockScopeForSession(runSessionManager);
|
|
284
|
+
const lockResult = await acquireWriterLock(lockScope, "gui");
|
|
285
|
+
if (lockResult.role !== "writer") {
|
|
286
|
+
writeJson(res, { error: "Read-only follower mode", lock: lockResult.lock }, 409);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
acquiredLockScope = lockScope;
|
|
290
|
+
lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
activeRunSessionIds.add(sessionId);
|
|
294
|
+
let createdSession: { session: AgentSession } | null = null;
|
|
295
|
+
try {
|
|
296
|
+
createdSession = useCurrentSession
|
|
297
|
+
? null
|
|
298
|
+
: await options.createSessionForManager(runSessionManager);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
activeRunSessionIds.delete(sessionId);
|
|
301
|
+
if (lockHeartbeat) clearInterval(lockHeartbeat);
|
|
302
|
+
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
303
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
304
|
+
writeJson(res, { error: message }, 500);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
res.writeHead(200, {
|
|
309
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
310
|
+
"cache-control": "no-cache, no-transform",
|
|
311
|
+
connection: "keep-alive",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
let seq = 1;
|
|
315
|
+
const runId = `gui-run-${Date.now()}`;
|
|
316
|
+
const runSession = createdSession?.session ?? options.getSession();
|
|
317
|
+
if (!prompt.startsWith("/") && !runSessionManager.getSessionName()) {
|
|
318
|
+
runSessionManager.appendSessionInfo(prompt.length > 80 ? `${prompt.slice(0, 77)}...` : prompt);
|
|
319
|
+
}
|
|
320
|
+
const beforeEntries = runSessionManager.getEntries();
|
|
321
|
+
const beforeCount = beforeEntries.length;
|
|
322
|
+
const beforeIds = new Set(beforeEntries.map((entry) => entry.id));
|
|
323
|
+
writeSse(res, { type: "run.started", runId, sessionId, seq: seq++ });
|
|
324
|
+
res.flushHeaders?.();
|
|
325
|
+
const liveStartSeq = seq;
|
|
326
|
+
const liveAdapter = createLiveChatEventAdapter({
|
|
327
|
+
runId,
|
|
328
|
+
sessionId,
|
|
329
|
+
startSeq: seq,
|
|
330
|
+
emit: (event) => writeSse(res, event),
|
|
331
|
+
originalPrompt: prompt,
|
|
332
|
+
});
|
|
333
|
+
const observation = createPromptObservation();
|
|
334
|
+
const unsubscribeLive = runSession.subscribe((event) => {
|
|
335
|
+
liveAdapter.handle(event);
|
|
336
|
+
observePromptEvent(observation, event);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
|
|
341
|
+
if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
|
|
342
|
+
runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
|
|
343
|
+
const message =
|
|
344
|
+
modelSetup.requirement === "select_model"
|
|
345
|
+
? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
|
|
346
|
+
: "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
|
|
347
|
+
runSessionManager.appendCustomMessageEntry("opencandle-model-setup", message, true, {
|
|
348
|
+
source: "gui",
|
|
349
|
+
requirement: modelSetup.requirement,
|
|
350
|
+
});
|
|
351
|
+
broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
352
|
+
} else {
|
|
353
|
+
await promptAndSettle(runSession, prompt, beforeIds, observation);
|
|
354
|
+
broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
355
|
+
}
|
|
356
|
+
seq = liveAdapter.nextSeq();
|
|
357
|
+
if (seq === liveStartSeq) {
|
|
358
|
+
await waitForNewEntryId(
|
|
359
|
+
() => runSessionManager.getEntries().map((entry) => entry.id),
|
|
360
|
+
beforeIds,
|
|
361
|
+
);
|
|
362
|
+
const newEntries = runSessionManager
|
|
363
|
+
.getEntries()
|
|
364
|
+
.slice(beforeCount)
|
|
365
|
+
.filter((entry) => !beforeIds.has(entry.id));
|
|
366
|
+
const events = sessionEntriesToChatEvents(newEntries, {
|
|
367
|
+
sessionId,
|
|
368
|
+
updatedAt: new Date().toISOString(),
|
|
369
|
+
startSeq: seq,
|
|
370
|
+
});
|
|
371
|
+
for (const event of events) {
|
|
372
|
+
writeSse(res, event);
|
|
373
|
+
seq = event.seq + 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
writeSse(res, { type: "run.completed", runId, sessionId, seq });
|
|
377
|
+
} catch (error) {
|
|
378
|
+
seq = liveAdapter.nextSeq();
|
|
379
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
380
|
+
writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
|
|
381
|
+
} finally {
|
|
382
|
+
activeRunSessionIds.delete(sessionId);
|
|
383
|
+
unsubscribeLive();
|
|
384
|
+
createdSession?.session.dispose();
|
|
385
|
+
if (lockHeartbeat) clearInterval(lockHeartbeat);
|
|
386
|
+
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
387
|
+
res.end();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function broadcastRunSessionSnapshot(
|
|
392
|
+
options: GuiHttpRouteOptions,
|
|
393
|
+
sessionManager: SessionManager,
|
|
394
|
+
useCurrentSession: boolean,
|
|
395
|
+
): void {
|
|
396
|
+
if (useCurrentSession) {
|
|
397
|
+
options.wsHub.broadcastState();
|
|
398
|
+
} else {
|
|
399
|
+
options.wsHub.broadcastSessionSnapshot(sessionManager);
|
|
400
|
+
options.wsHub.broadcastSessions();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function buildSessionBootstrapPayload(
|
|
405
|
+
options: Pick<
|
|
406
|
+
GuiHttpRouteOptions,
|
|
407
|
+
"cwd" | "sessionDir" | "role" | "modelSetupController" | "getSessionManager" | "wsHub"
|
|
408
|
+
>,
|
|
409
|
+
sessionManager: SessionManager,
|
|
410
|
+
): Promise<Record<string, unknown>> {
|
|
411
|
+
const sessionId = sessionManager.getSessionId();
|
|
412
|
+
const entries = sessionManager.getEntries();
|
|
413
|
+
const bootstrap = await options.wsHub.buildBootstrapPayload();
|
|
414
|
+
return {
|
|
415
|
+
role: roleForSessionBootstrap(options, sessionManager),
|
|
416
|
+
sessionId,
|
|
417
|
+
catalog: buildCatalog(),
|
|
418
|
+
modelSetup: options.modelSetupController.buildCurrentModelSetupState(),
|
|
419
|
+
askUserPrompts: Array.isArray(bootstrap.askUserPrompts) ? bootstrap.askUserPrompts : [],
|
|
420
|
+
sessions: await SessionManager.list(options.cwd, options.sessionDir),
|
|
421
|
+
snapshot: {
|
|
422
|
+
sessionId,
|
|
423
|
+
state: projectDashboard(entries, sessionId),
|
|
424
|
+
entries,
|
|
425
|
+
events: sessionEntriesToChatEvents(entries, {
|
|
426
|
+
sessionId,
|
|
427
|
+
title: sessionManager.getSessionName(),
|
|
428
|
+
}),
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function roleForSessionBootstrap(
|
|
434
|
+
options: Pick<GuiHttpRouteOptions, "role" | "getSessionManager">,
|
|
435
|
+
sessionManager: SessionManager,
|
|
436
|
+
): string {
|
|
437
|
+
if (options.role !== "writer") return options.role;
|
|
438
|
+
const currentSessionManager = options.getSessionManager();
|
|
439
|
+
if (currentSessionManager.getSessionFile() === sessionManager.getSessionFile()) return "writer";
|
|
440
|
+
const lock = readWriterLock(writerLockScopeForSession(sessionManager));
|
|
441
|
+
return lock && lock.pid !== process.pid ? "follower" : "writer";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function resolveSessionManagerById(
|
|
445
|
+
options: Pick<GuiHttpRouteOptions, "cwd" | "sessionDir" | "getSessionManager">,
|
|
446
|
+
sessionId: string,
|
|
447
|
+
): Promise<SessionManager | null> {
|
|
448
|
+
const currentSessionManager = options.getSessionManager();
|
|
449
|
+
if (currentSessionManager.getSessionId() === sessionId) return currentSessionManager;
|
|
450
|
+
const sessions = await SessionManager.list(options.cwd, options.sessionDir);
|
|
451
|
+
const match = sessions.find((candidate) => candidate.id === sessionId);
|
|
452
|
+
return match ? SessionManager.open(match.path, options.sessionDir, options.cwd) : null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function sessionIdFromRoute(pathname: string, action: "bootstrap" | "runs"): string {
|
|
456
|
+
const match = pathname.match(new RegExp(`^/api/sessions/([^/]+)/${action}$`));
|
|
457
|
+
if (!match) return "";
|
|
458
|
+
try {
|
|
459
|
+
return decodeURIComponent(match[1] ?? "");
|
|
460
|
+
} catch {
|
|
461
|
+
return "";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function handleTrustedGuiMutation(
|
|
466
|
+
req: IncomingMessage,
|
|
467
|
+
res: ServerResponse,
|
|
468
|
+
options: GuiHttpRouteOptions,
|
|
469
|
+
action: (body: Record<string, unknown>) => Promise<void>,
|
|
470
|
+
): Promise<void> {
|
|
471
|
+
try {
|
|
472
|
+
await action(asRecord(await readJsonBody(req)));
|
|
473
|
+
writeJson(res, await options.wsHub.buildBootstrapPayload());
|
|
474
|
+
} catch (error) {
|
|
475
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
476
|
+
writeJson(res, { error: message }, message === "Read-only follower mode" ? 409 : 400);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function serveStaticAsset(
|
|
481
|
+
pathname: string,
|
|
482
|
+
res: ServerResponse,
|
|
483
|
+
options: GuiHttpRouteOptions,
|
|
484
|
+
): void {
|
|
485
|
+
const requested = pathname === "/" ? "/index.html" : pathname;
|
|
486
|
+
const path = resolve(join(options.webDist, requested));
|
|
487
|
+
if (!path.startsWith(options.webDist) || !existsSync(path)) {
|
|
488
|
+
const fallback = resolve(join(options.webDist, "index.html"));
|
|
489
|
+
if (!extname(requested) && fallback.startsWith(options.webDist) && existsSync(fallback)) {
|
|
490
|
+
res.writeHead(200, privateGuiHeaders("text/html; charset=utf-8", options));
|
|
491
|
+
createReadStream(fallback).pipe(res);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
res.writeHead(404).end("Not found");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const type = contentType(path);
|
|
499
|
+
res.writeHead(200, privateGuiHeaders(type, options));
|
|
500
|
+
createReadStream(path).pipe(res);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function writeJson(res: ServerResponse, value: unknown, status = 200): void {
|
|
504
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
505
|
+
res.end(JSON.stringify(value));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function allowTrustedGuiRequest(
|
|
509
|
+
req: IncomingMessage,
|
|
510
|
+
res: ServerResponse,
|
|
511
|
+
label: string,
|
|
512
|
+
options: GuiHttpRouteOptions,
|
|
513
|
+
): boolean {
|
|
514
|
+
if (
|
|
515
|
+
isTrustedPrivateApiRequest(
|
|
516
|
+
req.headers,
|
|
517
|
+
options.privateApiSessionToken,
|
|
518
|
+
req.socket.remoteAddress,
|
|
519
|
+
{
|
|
520
|
+
allowRemote: options.allowRemotePrivateApi,
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
return true;
|
|
525
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
526
|
+
res.end(
|
|
527
|
+
JSON.stringify({
|
|
528
|
+
error: `${label} is only available to trusted GUI browser sessions.`,
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function privateGuiHeaders(
|
|
535
|
+
contentTypeValue: string,
|
|
536
|
+
options: GuiHttpRouteOptions,
|
|
537
|
+
): Record<string, string> {
|
|
538
|
+
const headers: Record<string, string> = { "content-type": contentTypeValue };
|
|
539
|
+
if (contentTypeValue.startsWith("text/html")) {
|
|
540
|
+
headers["set-cookie"] = privateApiCookieHeader(options.privateApiSessionToken);
|
|
541
|
+
}
|
|
542
|
+
return headers;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function writeSse(res: ServerResponse, event: ChatEvent): void {
|
|
546
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
550
|
+
const chunks: Buffer[] = [];
|
|
551
|
+
for await (const chunk of req) {
|
|
552
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
553
|
+
}
|
|
554
|
+
if (chunks.length === 0) return {};
|
|
555
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function contentType(path: string): string {
|
|
559
|
+
switch (extname(path)) {
|
|
560
|
+
case ".html":
|
|
561
|
+
return "text/html; charset=utf-8";
|
|
562
|
+
case ".css":
|
|
563
|
+
return "text/css; charset=utf-8";
|
|
564
|
+
case ".js":
|
|
565
|
+
return "text/javascript; charset=utf-8";
|
|
566
|
+
case ".svg":
|
|
567
|
+
return "image/svg+xml";
|
|
568
|
+
default:
|
|
569
|
+
return "application/octet-stream";
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
574
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
575
|
+
? (value as Record<string, unknown>)
|
|
576
|
+
: {};
|
|
577
|
+
}
|