opencandle 0.10.0 → 0.11.1

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 (223) hide show
  1. package/README.md +12 -9
  2. package/dist/analysts/orchestrator.d.ts +3 -4
  3. package/dist/analysts/orchestrator.js +34 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/doctor/report.js +1 -1
  11. package/dist/doctor/report.js.map +1 -1
  12. package/dist/infra/cache.d.ts +6 -0
  13. package/dist/infra/cache.js +16 -6
  14. package/dist/infra/cache.js.map +1 -1
  15. package/dist/infra/freshness.d.ts +21 -0
  16. package/dist/infra/freshness.js +119 -0
  17. package/dist/infra/freshness.js.map +1 -0
  18. package/dist/infra/market-calendar.d.ts +14 -0
  19. package/dist/infra/market-calendar.js +137 -0
  20. package/dist/infra/market-calendar.js.map +1 -0
  21. package/dist/infra/rate-limiter.js +1 -0
  22. package/dist/infra/rate-limiter.js.map +1 -1
  23. package/dist/market-state/daily-report.d.ts +1 -1
  24. package/dist/market-state/daily-report.js +5 -16
  25. package/dist/market-state/daily-report.js.map +1 -1
  26. package/dist/market-state/service.d.ts +0 -35
  27. package/dist/market-state/service.js +0 -63
  28. package/dist/market-state/service.js.map +1 -1
  29. package/dist/market-state/summaries.d.ts +8 -0
  30. package/dist/market-state/summaries.js +70 -0
  31. package/dist/market-state/summaries.js.map +1 -0
  32. package/dist/memory/sqlite.js +20 -19
  33. package/dist/memory/sqlite.js.map +1 -1
  34. package/dist/onboarding/providers.d.ts +25 -1
  35. package/dist/onboarding/providers.js +26 -0
  36. package/dist/onboarding/providers.js.map +1 -1
  37. package/dist/pi/opencandle-extension.js +57 -226
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-action-dedupe.d.ts +6 -0
  40. package/dist/pi/session-action-dedupe.js +126 -0
  41. package/dist/pi/session-action-dedupe.js.map +1 -0
  42. package/dist/pi/session-writer-lock.d.ts +26 -2
  43. package/dist/pi/session-writer-lock.js +230 -18
  44. package/dist/pi/session-writer-lock.js.map +1 -1
  45. package/dist/pi/setup.js +5 -5
  46. package/dist/pi/setup.js.map +1 -1
  47. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  48. package/dist/pi/tui-session-coordinator.js +283 -0
  49. package/dist/pi/tui-session-coordinator.js.map +1 -0
  50. package/dist/prompts/context-builder.js +1 -1
  51. package/dist/prompts/policy-cards.js +1 -1
  52. package/dist/prompts/policy-cards.js.map +1 -1
  53. package/dist/providers/alpha-vantage.js +6 -0
  54. package/dist/providers/alpha-vantage.js.map +1 -1
  55. package/dist/providers/coingecko.js +1 -0
  56. package/dist/providers/coingecko.js.map +1 -1
  57. package/dist/providers/polymarket.d.ts +2 -0
  58. package/dist/providers/polymarket.js +163 -0
  59. package/dist/providers/polymarket.js.map +1 -0
  60. package/dist/providers/wrap-provider.js +5 -3
  61. package/dist/providers/wrap-provider.js.map +1 -1
  62. package/dist/providers/yahoo-finance.d.ts +3 -0
  63. package/dist/providers/yahoo-finance.js +206 -10
  64. package/dist/providers/yahoo-finance.js.map +1 -1
  65. package/dist/routing/classify-intent.js +4 -5
  66. package/dist/routing/classify-intent.js.map +1 -1
  67. package/dist/routing/entity-extractor.js +16 -0
  68. package/dist/routing/entity-extractor.js.map +1 -1
  69. package/dist/routing/route-manifest.js +1 -2
  70. package/dist/routing/route-manifest.js.map +1 -1
  71. package/dist/routing/router-llm-client.js +18 -3
  72. package/dist/routing/router-llm-client.js.map +1 -1
  73. package/dist/routing/router-prompt.js +1 -1
  74. package/dist/routing/router.d.ts +1 -1
  75. package/dist/routing/router.js +309 -9
  76. package/dist/routing/router.js.map +1 -1
  77. package/dist/routing/slot-resolver.d.ts +1 -0
  78. package/dist/routing/slot-resolver.js +1 -1
  79. package/dist/routing/slot-resolver.js.map +1 -1
  80. package/dist/runtime/evidence.d.ts +1 -0
  81. package/dist/runtime/evidence.js.map +1 -1
  82. package/dist/runtime/numeric-claims.d.ts +23 -0
  83. package/dist/runtime/numeric-claims.js +100 -0
  84. package/dist/runtime/numeric-claims.js.map +1 -0
  85. package/dist/runtime/planning-evidence.js +1 -76
  86. package/dist/runtime/planning-evidence.js.map +1 -1
  87. package/dist/runtime/prompt-step.d.ts +10 -1
  88. package/dist/runtime/prompt-step.js +116 -2
  89. package/dist/runtime/prompt-step.js.map +1 -1
  90. package/dist/runtime/session-coordinator.d.ts +11 -1
  91. package/dist/runtime/session-coordinator.js +354 -62
  92. package/dist/runtime/session-coordinator.js.map +1 -1
  93. package/dist/runtime/validation.d.ts +3 -1
  94. package/dist/runtime/workflow-events.d.ts +1 -1
  95. package/dist/runtime/workflow-events.js.map +1 -1
  96. package/dist/runtime/workflow-types.d.ts +3 -0
  97. package/dist/runtime/workflow-types.js.map +1 -1
  98. package/dist/system-prompt.js +1 -1
  99. package/dist/tools/fundamentals/comps.d.ts +1 -0
  100. package/dist/tools/fundamentals/comps.js +20 -9
  101. package/dist/tools/fundamentals/comps.js.map +1 -1
  102. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  103. package/dist/tools/fundamentals/dcf.js +190 -76
  104. package/dist/tools/fundamentals/dcf.js.map +1 -1
  105. package/dist/tools/index.d.ts +1 -1
  106. package/dist/tools/index.js +3 -3
  107. package/dist/tools/index.js.map +1 -1
  108. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  109. package/dist/tools/macro/event-probabilities.js +121 -0
  110. package/dist/tools/macro/event-probabilities.js.map +1 -0
  111. package/dist/tools/market/crypto-price.d.ts +4 -1
  112. package/dist/tools/market/crypto-price.js +10 -1
  113. package/dist/tools/market/crypto-price.js.map +1 -1
  114. package/dist/tools/market/screen-stocks.js +14 -3
  115. package/dist/tools/market/screen-stocks.js.map +1 -1
  116. package/dist/tools/market/stock-quote.d.ts +4 -1
  117. package/dist/tools/market/stock-quote.js +9 -4
  118. package/dist/tools/market/stock-quote.js.map +1 -1
  119. package/dist/tools/options/option-chain.d.ts +4 -1
  120. package/dist/tools/options/option-chain.js +12 -1
  121. package/dist/tools/options/option-chain.js.map +1 -1
  122. package/dist/tools/portfolio/daily-report.js +10 -4
  123. package/dist/tools/portfolio/daily-report.js.map +1 -1
  124. package/dist/tools/portfolio/risk-analysis.js +1 -1
  125. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  126. package/dist/tools/technical/backtest.d.ts +23 -5
  127. package/dist/tools/technical/backtest.js +131 -94
  128. package/dist/tools/technical/backtest.js.map +1 -1
  129. package/dist/tools/technical/indicators.js +1 -1
  130. package/dist/tools/technical/indicators.js.map +1 -1
  131. package/dist/types/fundamentals.d.ts +1 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/market.d.ts +2 -0
  134. package/dist/types/options.d.ts +1 -0
  135. package/dist/types/prediction-markets.d.ts +13 -0
  136. package/dist/types/prediction-markets.js +2 -0
  137. package/dist/types/prediction-markets.js.map +1 -0
  138. package/gui/server/chat-event-adapter.ts +89 -5
  139. package/gui/server/http-routes.ts +661 -37
  140. package/gui/server/invoke-tool.ts +165 -16
  141. package/gui/server/live-chat-event-adapter.ts +37 -2
  142. package/gui/server/local-session-coordinator.ts +97 -0
  143. package/gui/server/market-state-api.ts +64 -48
  144. package/gui/server/projector.ts +132 -3
  145. package/gui/server/server.ts +51 -6
  146. package/gui/server/session-actions.ts +146 -45
  147. package/gui/server/session-entry-wait.ts +22 -3
  148. package/gui/server/tool-metadata.ts +3 -1
  149. package/gui/server/ws-hub.ts +77 -10
  150. package/gui/shared/chat-events.ts +10 -1
  151. package/gui/shared/event-reducer.ts +1 -0
  152. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  153. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  154. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  155. package/gui/web/dist/index.html +2 -2
  156. package/package.json +13 -15
  157. package/src/analysts/orchestrator.ts +35 -56
  158. package/src/cli-main.ts +253 -13
  159. package/src/config.ts +12 -20
  160. package/src/doctor/report.ts +1 -1
  161. package/src/infra/cache.ts +24 -11
  162. package/src/infra/freshness.ts +165 -0
  163. package/src/infra/market-calendar.ts +193 -0
  164. package/src/infra/rate-limiter.ts +1 -0
  165. package/src/market-state/daily-report.ts +6 -16
  166. package/src/market-state/service.ts +0 -136
  167. package/src/market-state/summaries.ts +75 -0
  168. package/src/memory/sqlite.ts +23 -19
  169. package/src/onboarding/providers.ts +29 -1
  170. package/src/pi/opencandle-extension.ts +67 -270
  171. package/src/pi/session-action-dedupe.ts +155 -0
  172. package/src/pi/session-writer-lock.ts +290 -20
  173. package/src/pi/setup.ts +6 -6
  174. package/src/pi/tui-session-coordinator.ts +351 -0
  175. package/src/prompts/context-builder.ts +1 -1
  176. package/src/prompts/policy-cards.ts +1 -1
  177. package/src/providers/alpha-vantage.ts +6 -0
  178. package/src/providers/coingecko.ts +2 -0
  179. package/src/providers/polymarket.ts +214 -0
  180. package/src/providers/wrap-provider.ts +5 -3
  181. package/src/providers/yahoo-finance.ts +339 -27
  182. package/src/routing/classify-intent.ts +4 -5
  183. package/src/routing/entity-extractor.ts +17 -0
  184. package/src/routing/route-manifest.ts +1 -2
  185. package/src/routing/router-llm-client.ts +31 -19
  186. package/src/routing/router-prompt.ts +1 -1
  187. package/src/routing/router.ts +379 -8
  188. package/src/routing/slot-resolver.ts +1 -1
  189. package/src/runtime/evidence.ts +1 -0
  190. package/src/runtime/numeric-claims.ts +108 -0
  191. package/src/runtime/planning-evidence.ts +6 -91
  192. package/src/runtime/prompt-step.ts +127 -2
  193. package/src/runtime/session-coordinator.ts +464 -74
  194. package/src/runtime/validation.ts +1 -1
  195. package/src/runtime/workflow-events.ts +2 -0
  196. package/src/runtime/workflow-types.ts +3 -0
  197. package/src/system-prompt.ts +1 -1
  198. package/src/tools/AGENTS.md +1 -1
  199. package/src/tools/fundamentals/comps.ts +25 -14
  200. package/src/tools/fundamentals/dcf.ts +217 -87
  201. package/src/tools/index.ts +3 -3
  202. package/src/tools/macro/event-probabilities.ts +141 -0
  203. package/src/tools/market/crypto-price.ts +14 -2
  204. package/src/tools/market/screen-stocks.ts +14 -3
  205. package/src/tools/market/stock-quote.ts +49 -43
  206. package/src/tools/options/option-chain.ts +16 -2
  207. package/src/tools/portfolio/daily-report.ts +10 -4
  208. package/src/tools/portfolio/risk-analysis.ts +1 -1
  209. package/src/tools/technical/backtest.ts +167 -108
  210. package/src/tools/technical/indicators.ts +1 -1
  211. package/src/types/fundamentals.ts +1 -0
  212. package/src/types/index.ts +1 -0
  213. package/src/types/market.ts +2 -0
  214. package/src/types/options.ts +1 -0
  215. package/src/types/prediction-markets.ts +13 -0
  216. package/dist/tools/portfolio/predictions.d.ts +0 -55
  217. package/dist/tools/portfolio/predictions.js +0 -422
  218. package/dist/tools/portfolio/predictions.js.map +0 -1
  219. package/gui/server/chat-run-session.ts +0 -16
  220. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  221. package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
  222. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  223. package/src/tools/portfolio/predictions.ts +0 -553
@@ -3,12 +3,35 @@ import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import { extname, join, resolve } from "node:path";
4
4
  import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
5
  import { buildDoctorReport } from "../../src/doctor/report.js";
6
+ import { MarketStateService } from "../../src/market-state/service.js";
7
+ import {
8
+ formatLatestReportSummary,
9
+ formatPortfolioSummary,
10
+ formatWatchlistSummary,
11
+ } from "../../src/market-state/summaries.js";
12
+ import { initDefaultDatabase } from "../../src/memory/sqlite.js";
6
13
  import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
14
+ import {
15
+ clearPendingSessionAction,
16
+ hasAcceptedSessionAction,
17
+ hasPendingSessionAction,
18
+ recordAcceptedSessionAction,
19
+ recordPendingSessionAction,
20
+ } from "../../src/pi/session-action-dedupe.js";
7
21
  import type { ChatEvent } from "../shared/chat-events.js";
8
22
  import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
9
- import { chatRunSessionConflict } from "./chat-run-session.js";
23
+ import type { ToolInvokeController } from "./invoke-tool.js";
10
24
  import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
11
- import { buildMarketStateSnapshot, searchInstrumentCandidates } from "./market-state-api.js";
25
+ import type {
26
+ LocalSessionCoordinator,
27
+ SessionActionEnvelope,
28
+ SessionActionResult,
29
+ } from "./local-session-coordinator.js";
30
+ import {
31
+ buildMarketStateSnapshot,
32
+ getSavedMarketStateSymbols,
33
+ searchInstrumentCandidates,
34
+ } from "./market-state-api.js";
12
35
  import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
13
36
  import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
14
37
  import { projectDashboard } from "./projector.js";
@@ -19,9 +42,11 @@ import { waitForNewEntryId } from "./session-entry-wait.js";
19
42
  import { buildCatalog } from "./tool-metadata.js";
20
43
  import {
21
44
  acquireWriterLock,
45
+ isCoordinatorOwnerAlive,
22
46
  readWriterLock,
23
47
  refreshWriterLock,
24
48
  releaseWriterLock,
49
+ shouldBlockFailedCoordinatorAction as shouldBlockFailedCoordinatorLockAction,
25
50
  writerLockScopeForSession,
26
51
  } from "./writer-lock.js";
27
52
  import type { WsHub } from "./ws-hub.js";
@@ -35,16 +60,43 @@ interface GuiHttpRouteOptions {
35
60
  agentDir: string;
36
61
  sessionDir: string;
37
62
  privateApiSessionToken: string;
63
+ localCoordinatorEndpoint: string;
64
+ localCoordinatorSecret: string;
38
65
  allowRemotePrivateApi: boolean;
66
+ syncCurrentWriterLockScope?: () => void;
39
67
  getSession: () => AgentSession;
40
68
  getSessionManager: () => SessionManager;
41
69
  createSessionForManager: (sessionManager: SessionManager) => Promise<{ session: AgentSession }>;
42
70
  wsHub: WsHub;
43
71
  modelSetupController: ModelSetupController;
44
72
  sessionActionsController: SessionActionsController;
73
+ toolInvokeController: ToolInvokeController;
45
74
  quoteSnapshotStore: QuoteSnapshotStore;
75
+ localSessionCoordinator?: LocalSessionCoordinator;
46
76
  }
47
77
 
78
+ export interface ChatRunImageInput {
79
+ data: string;
80
+ mimeType: string;
81
+ }
82
+
83
+ export type ChatRunAttachmentInput =
84
+ | { kind: "portfolio"; id?: string }
85
+ | { kind: "watchlist"; id: string }
86
+ | { kind: "report"; id: string };
87
+
88
+ export interface ParsedChatRunBody {
89
+ prompt: string;
90
+ images: ChatRunImageInput[];
91
+ attachments: ChatRunAttachmentInput[];
92
+ }
93
+
94
+ type ChatRunParseResult = { ok: true; value: ParsedChatRunBody } | { ok: false; error: string };
95
+
96
+ const CHAT_RUN_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
97
+ const CHAT_RUN_MAX_IMAGE_COUNT = 4;
98
+ const CHAT_RUN_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
99
+
48
100
  export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
49
101
  const activeRunSessionIds = new Set<string>();
50
102
 
@@ -67,7 +119,11 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
67
119
  if (url.pathname === "/api/session/new" && req.method === "POST") {
68
120
  if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
69
121
  if (options.role !== "writer") {
70
- writeJson(res, { error: "Read-only follower mode" }, 409);
122
+ writeJson(
123
+ res,
124
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
125
+ 409,
126
+ );
71
127
  return;
72
128
  }
73
129
  await options.sessionActionsController.handleNewSession();
@@ -211,19 +267,109 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
211
267
 
212
268
  if (url.pathname === "/api/chat/run" && req.method === "POST") {
213
269
  if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
214
- await handleSseChatRun(req, res, options, activeRunSessionIds);
270
+ writeJson(
271
+ res,
272
+ {
273
+ error: "Legacy active-session chat runs are no longer supported.",
274
+ code: "legacy_route_removed",
275
+ },
276
+ 410,
277
+ );
278
+ return;
279
+ }
280
+
281
+ if (url.pathname === "/api/local-coordinator/chat-run" && req.method === "POST") {
282
+ if (!allowLocalCoordinatorRequest(req, res, options)) return;
283
+ const body = asRecord(await readJsonBody(req));
284
+ if (!requireSessionActionFields(res, body)) return;
285
+ const sessionManager = await resolveSessionManagerById(options, String(body.sessionId));
286
+ if (!sessionManager) {
287
+ writeJson(res, { error: "Unknown saved session" }, 404);
288
+ return;
289
+ }
290
+ // allowProxy: false — this endpoint is the proxy target; re-proxying loops.
291
+ await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body, false);
292
+ return;
293
+ }
294
+
295
+ if (url.pathname === "/api/local-coordinator/tool-invoke" && req.method === "POST") {
296
+ if (!allowLocalCoordinatorRequest(req, res, options)) return;
297
+ const body = asRecord(await readJsonBody(req));
298
+ if (!requireSessionActionFields(res, body)) return;
299
+ let ack: unknown;
300
+ await options.toolInvokeController.handleToolInvokeMessage(
301
+ { send: (message) => (ack = message) },
302
+ {
303
+ requestId: "local-coordinator-tool",
304
+ actionId: String(body.actionId ?? ""),
305
+ sessionId: String(body.sessionId ?? ""),
306
+ toolName: String(body.toolName ?? ""),
307
+ args: asRecord(body.args),
308
+ allowProxy: false,
309
+ },
310
+ );
311
+ const message = asRecord(ack);
312
+ const result = asRecord(message.result);
313
+ if (result.toolCallId) {
314
+ writeJson(res, { result });
315
+ } else {
316
+ writeJson(
317
+ res,
318
+ { error: String(asRecord(message.error).message ?? "Tool invocation failed") },
319
+ 409,
320
+ );
321
+ }
322
+ return;
323
+ }
324
+
325
+ if (url.pathname === "/api/local-coordinator/ask-user" && req.method === "POST") {
326
+ if (!allowLocalCoordinatorRequest(req, res, options)) return;
327
+ const body = asRecord(await readJsonBody(req));
328
+ if (!requireSessionActionFields(res, body)) return;
329
+ const payload = asRecord(body.payload);
330
+ try {
331
+ const action = {
332
+ actionId: String(body.actionId ?? ""),
333
+ sessionId: String(body.sessionId ?? ""),
334
+ source: "browser" as const,
335
+ allowProxy: false,
336
+ };
337
+ if (body.actionType === "ask_user.answer") {
338
+ await options.sessionActionsController.handleAskUserAnswer(
339
+ String(payload.id ?? ""),
340
+ payload.answer,
341
+ action,
342
+ );
343
+ } else if (body.actionType === "ask_user.cancel") {
344
+ await options.sessionActionsController.handleAskUserCancel(
345
+ String(payload.id ?? ""),
346
+ action,
347
+ );
348
+ } else {
349
+ writeJson(res, { error: "Unknown ask_user action" }, 400);
350
+ return;
351
+ }
352
+ writeJson(res, { ok: true });
353
+ } catch (error) {
354
+ const message = error instanceof Error ? error.message : String(error);
355
+ writeJson(res, { error: message }, 409);
356
+ }
215
357
  return;
216
358
  }
217
359
 
218
360
  const runSessionId = sessionIdFromRoute(url.pathname, "runs");
219
361
  if (runSessionId && req.method === "POST") {
220
362
  if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
363
+ const body = asRecord(await readJsonBody(req));
364
+ if (!requireSessionActionFields(res, { ...body, sessionId: runSessionId }, false)) return;
221
365
  const sessionManager = await resolveSessionManagerById(options, runSessionId);
222
366
  if (!sessionManager) {
223
367
  writeJson(res, { error: "Unknown saved session" }, 404);
224
368
  return;
225
369
  }
226
- await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager);
370
+ // allowProxy: true forward to a live coordinator owned by another
371
+ // process (0.11.0 authenticated local forwarding).
372
+ await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body, true);
227
373
  return;
228
374
  }
229
375
 
@@ -236,31 +382,26 @@ async function handleSseChatRun(
236
382
  res: ServerResponse,
237
383
  options: GuiHttpRouteOptions,
238
384
  activeRunSessionIds: Set<string>,
239
- targetSessionManager?: SessionManager,
385
+ targetSessionManager: SessionManager | undefined,
386
+ bodyOverride: Record<string, unknown> | undefined,
387
+ // Explicit because both remaining callers pass a body: the browser-facing
388
+ // runs route must forward to a live coordinator owned by another process,
389
+ // while the local-coordinator endpoint IS the proxy target and re-proxying
390
+ // there would loop.
391
+ allowProxy: boolean,
240
392
  ): Promise<void> {
241
- if (options.role !== "writer") {
242
- writeJson(res, { error: "Read-only follower mode" }, 409);
393
+ const body = bodyOverride ?? asRecord(await readJsonBody(req));
394
+ const parsed = parseChatRunBody(body);
395
+ if (!parsed.ok) {
396
+ writeJson(res, { error: parsed.error }, 400);
243
397
  return;
244
398
  }
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
399
  const currentSessionManager = options.getSessionManager();
254
- const requestedSessionId = String(asRecord(body).sessionId ?? "").trim();
400
+ const bodyRecord = asRecord(body);
401
+ const requestedSessionId = String(bodyRecord.sessionId ?? "").trim();
255
402
  const runSessionManager = targetSessionManager ?? currentSessionManager;
256
403
  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) {
404
+ if (requestedSessionId && requestedSessionId !== sessionId) {
264
405
  writeJson(
265
406
  res,
266
407
  { error: "Session route and request body disagree", code: "session_changed" },
@@ -269,22 +410,212 @@ async function handleSseChatRun(
269
410
  return;
270
411
  }
271
412
 
413
+ const actionId = String(bodyRecord.actionId ?? "").trim();
414
+ const shouldProxyChatRun = allowProxy && canProxyChatRunToCoordinator(runSessionManager);
415
+ if (shouldProxyChatRun) {
416
+ if (await proxyChatRunToCoordinator(res, runSessionManager, bodyRecord)) {
417
+ const useCurrentSession =
418
+ !targetSessionManager ||
419
+ currentSessionManager.getSessionFile() === runSessionManager.getSessionFile();
420
+ await broadcastFreshRunSessionSnapshot(options, runSessionManager, useCurrentSession);
421
+ return;
422
+ }
423
+ if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
424
+ writeJson(res, { ok: true, duplicate: true });
425
+ return;
426
+ }
427
+ if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
428
+ writeJson(
429
+ res,
430
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
431
+ 409,
432
+ );
433
+ return;
434
+ }
435
+ if (shouldBlockFailedCoordinatorAction(runSessionManager, bodyRecord)) {
436
+ writeJson(
437
+ res,
438
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
439
+ 409,
440
+ );
441
+ return;
442
+ }
443
+ }
444
+ if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
445
+ writeJson(res, { ok: true, duplicate: true });
446
+ return;
447
+ }
448
+ if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
449
+ writeJson(res, { error: "OpenCandle is reconnecting to this session.", code: "syncing" }, 409);
450
+ return;
451
+ }
452
+
453
+ if (options.localSessionCoordinator) {
454
+ const action = buildChatRunActionEnvelope(bodyRecord, sessionId);
455
+ let result: SessionActionResult<{ streamed: boolean }>;
456
+ try {
457
+ result = await options.localSessionCoordinator.runSessionAction(action, async () => {
458
+ const admitted = await streamAcceptedSseChatRun({
459
+ res,
460
+ options,
461
+ activeRunSessionIds,
462
+ targetSessionManager,
463
+ currentSessionManager,
464
+ runSessionManager,
465
+ sessionId,
466
+ parsedRun: parsed.value,
467
+ actionId: action.actionId,
468
+ });
469
+ if (!admitted) throw new SessionActionNotAdmitted();
470
+ return { streamed: true };
471
+ });
472
+ } catch (error) {
473
+ if (error instanceof SessionActionNotAdmitted) return;
474
+ throw error;
475
+ }
476
+ if (!result.ok) {
477
+ writeJson(res, { error: result.message, code: result.code }, 409);
478
+ return;
479
+ }
480
+ if (result.duplicate && !res.headersSent) {
481
+ writeJson(res, { ok: true, duplicate: true });
482
+ }
483
+ return;
484
+ }
485
+
486
+ await streamAcceptedSseChatRun({
487
+ res,
488
+ options,
489
+ activeRunSessionIds,
490
+ targetSessionManager,
491
+ currentSessionManager,
492
+ runSessionManager,
493
+ sessionId,
494
+ parsedRun: parsed.value,
495
+ actionId,
496
+ });
497
+ }
498
+
499
+ export function canProxyChatRunToCoordinator(runSessionManager: SessionManager): boolean {
500
+ const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
501
+ return hasLiveCoordinatorLock(lock);
502
+ }
503
+
504
+ async function proxyChatRunToCoordinator(
505
+ res: ServerResponse,
506
+ runSessionManager: SessionManager,
507
+ body: Record<string, unknown>,
508
+ ): Promise<boolean> {
509
+ const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
510
+ if (!hasLiveCoordinatorLock(lock)) return false;
511
+ const endpoint = new URL("/api/local-coordinator/chat-run", lock.coordinatorEndpoint);
512
+ let response: Response;
513
+ try {
514
+ response = await fetch(endpoint, {
515
+ method: "POST",
516
+ headers: {
517
+ "content-type": "application/json",
518
+ "x-opencandle-coordinator-secret": lock.coordinatorSecret,
519
+ },
520
+ body: JSON.stringify({ ...body, sessionId: runSessionManager.getSessionId() }),
521
+ });
522
+ } catch {
523
+ return false;
524
+ }
525
+ res.writeHead(response.status, Object.fromEntries(response.headers));
526
+ if (response.body) {
527
+ for await (const chunk of response.body) {
528
+ res.write(chunk);
529
+ }
530
+ }
531
+ res.end();
532
+ return true;
533
+ }
534
+
535
+ function hasLiveCoordinatorLock(
536
+ lock: ReturnType<typeof readWriterLock>,
537
+ staleGraceMs = 15_000,
538
+ ): lock is NonNullable<ReturnType<typeof readWriterLock>> & {
539
+ coordinatorEndpoint: string;
540
+ coordinatorSecret: string;
541
+ } {
542
+ if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret || lock.pid === process.pid) {
543
+ return false;
544
+ }
545
+ const heartbeat = Date.parse(lock.lastHeartbeat);
546
+ const hasFreshHeartbeat = Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs;
547
+ return hasFreshHeartbeat && isCoordinatorOwnerAlive(lock.pid);
548
+ }
549
+
550
+ async function streamAcceptedSseChatRun({
551
+ res,
552
+ options,
553
+ activeRunSessionIds,
554
+ targetSessionManager,
555
+ currentSessionManager,
556
+ runSessionManager,
557
+ sessionId,
558
+ parsedRun,
559
+ actionId,
560
+ }: {
561
+ res: ServerResponse;
562
+ options: GuiHttpRouteOptions;
563
+ activeRunSessionIds: Set<string>;
564
+ targetSessionManager?: SessionManager;
565
+ currentSessionManager: SessionManager;
566
+ runSessionManager: SessionManager;
567
+ sessionId: string;
568
+ parsedRun: ParsedChatRunBody;
569
+ actionId: string;
570
+ }): Promise<boolean> {
571
+ const prompt = parsedRun.prompt;
272
572
  if (activeRunSessionIds.has(sessionId)) {
273
573
  writeJson(res, { error: "Session already has an active run", code: "session_busy" }, 409);
274
- return;
574
+ return false;
575
+ }
576
+ let dispatchedPrompt: string;
577
+ try {
578
+ dispatchedPrompt = await buildDispatchedPrompt(parsedRun);
579
+ } catch (error) {
580
+ writeJson(res, { error: error instanceof Error ? error.message : String(error) }, 400);
581
+ return false;
275
582
  }
583
+ const promptImages = parsedRun.images.map((image) => ({
584
+ type: "image" as const,
585
+ data: image.data,
586
+ mimeType: image.mimeType,
587
+ }));
588
+ const attachmentLabels = parsedRun.attachments.map((attachment) => ({
589
+ kind: attachment.kind,
590
+ label: attachmentLabel(attachment),
591
+ }));
592
+ const inputAttachmentLabels = [
593
+ ...promptImages.map((image, index) => ({
594
+ kind: "image",
595
+ label: `${image.mimeType || "image"} #${index + 1}`,
596
+ })),
597
+ ...attachmentLabels,
598
+ ];
276
599
 
277
600
  const currentSessionFile = currentSessionManager.getSessionFile();
278
601
  const targetSessionFile = runSessionManager.getSessionFile();
279
602
  const useCurrentSession = !targetSessionManager || currentSessionFile === targetSessionFile;
280
603
  let acquiredLockScope = "";
281
604
  let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
282
- if (!useCurrentSession) {
605
+ const needsWriterLock = !useCurrentSession || options.role !== "writer";
606
+ if (needsWriterLock) {
283
607
  const lockScope = writerLockScopeForSession(runSessionManager);
284
- const lockResult = await acquireWriterLock(lockScope, "gui");
608
+ const lockResult = await acquireWriterLock(lockScope, "gui", {
609
+ coordinatorEndpoint: options.localCoordinatorEndpoint,
610
+ coordinatorSecret: options.localCoordinatorSecret,
611
+ });
285
612
  if (lockResult.role !== "writer") {
286
- writeJson(res, { error: "Read-only follower mode", lock: lockResult.lock }, 409);
287
- return;
613
+ writeJson(
614
+ res,
615
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
616
+ 409,
617
+ );
618
+ return false;
288
619
  }
289
620
  acquiredLockScope = lockScope;
290
621
  lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
@@ -302,7 +633,7 @@ async function handleSseChatRun(
302
633
  if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
303
634
  const message = error instanceof Error ? error.message : String(error);
304
635
  writeJson(res, { error: message }, 500);
305
- return;
636
+ return false;
306
637
  }
307
638
 
308
639
  res.writeHead(200, {
@@ -329,17 +660,45 @@ async function handleSseChatRun(
329
660
  startSeq: seq,
330
661
  emit: (event) => writeSse(res, event),
331
662
  originalPrompt: prompt,
663
+ originalAttachments: inputAttachmentLabels,
332
664
  });
333
665
  const observation = createPromptObservation();
666
+ let originalInputMarkerAppended = false;
667
+ const appendOriginalInputMarker = () => {
668
+ if (inputAttachmentLabels.length === 0 || originalInputMarkerAppended) return;
669
+ runSessionManager.appendCustomEntry("opencandle-user-input", {
670
+ original: prompt,
671
+ attachments: inputAttachmentLabels,
672
+ });
673
+ originalInputMarkerAppended = true;
674
+ };
675
+ let actionAccepted = false;
676
+ const recordAcceptedAction = () => {
677
+ if (actionAccepted) return;
678
+ appendOriginalInputMarker();
679
+ recordAcceptedSessionAction(runSessionManager, actionId);
680
+ if (useCurrentSession) options.syncCurrentWriterLockScope?.();
681
+ actionAccepted = true;
682
+ };
683
+
334
684
  const unsubscribeLive = runSession.subscribe((event) => {
335
685
  liveAdapter.handle(event);
336
686
  observePromptEvent(observation, event);
687
+ if (
688
+ !prompt.startsWith("/") &&
689
+ !actionAccepted &&
690
+ observation.userTexts.some((text) => text.trim() === dispatchedPrompt.trim())
691
+ ) {
692
+ recordAcceptedAction();
693
+ }
337
694
  });
338
695
 
339
696
  try {
697
+ recordPendingSessionAction(runSessionManager, actionId);
340
698
  const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
341
699
  if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
342
700
  runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
701
+ recordAcceptedAction();
343
702
  const message =
344
703
  modelSetup.requirement === "select_model"
345
704
  ? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
@@ -348,10 +707,17 @@ async function handleSseChatRun(
348
707
  source: "gui",
349
708
  requirement: modelSetup.requirement,
350
709
  });
351
- broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
710
+ await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
352
711
  } else {
353
- await promptAndSettle(runSession, prompt, beforeIds, observation);
354
- broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
712
+ await promptAndSettle(
713
+ runSession,
714
+ dispatchedPrompt,
715
+ beforeIds,
716
+ observation,
717
+ promptImages.length > 0 ? { images: promptImages } : undefined,
718
+ );
719
+ recordAcceptedAction();
720
+ await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
355
721
  }
356
722
  seq = liveAdapter.nextSeq();
357
723
  if (seq === liveStartSeq) {
@@ -375,6 +741,7 @@ async function handleSseChatRun(
375
741
  }
376
742
  writeSse(res, { type: "run.completed", runId, sessionId, seq });
377
743
  } catch (error) {
744
+ if (!actionAccepted) clearPendingSessionAction(runSessionManager, actionId);
378
745
  seq = liveAdapter.nextSeq();
379
746
  const message = error instanceof Error ? error.message : String(error);
380
747
  writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
@@ -386,14 +753,18 @@ async function handleSseChatRun(
386
753
  if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
387
754
  res.end();
388
755
  }
756
+ return actionAccepted;
389
757
  }
390
758
 
391
- function broadcastRunSessionSnapshot(
759
+ class SessionActionNotAdmitted extends Error {}
760
+
761
+ async function broadcastRunSessionSnapshot(
392
762
  options: GuiHttpRouteOptions,
393
763
  sessionManager: SessionManager,
394
764
  useCurrentSession: boolean,
395
- ): void {
765
+ ): Promise<void> {
396
766
  if (useCurrentSession) {
767
+ options.syncCurrentWriterLockScope?.();
397
768
  options.wsHub.broadcastState();
398
769
  } else {
399
770
  options.wsHub.broadcastSessionSnapshot(sessionManager);
@@ -401,6 +772,51 @@ function broadcastRunSessionSnapshot(
401
772
  }
402
773
  }
403
774
 
775
+ async function broadcastFreshRunSessionSnapshot(
776
+ options: GuiHttpRouteOptions,
777
+ sessionManager: SessionManager,
778
+ useCurrentSession: boolean,
779
+ ): Promise<void> {
780
+ const freshSessionManager = await reloadSessionManager(sessionManager, options);
781
+ if (useCurrentSession) {
782
+ options.syncCurrentWriterLockScope?.();
783
+ options.wsHub.broadcast({
784
+ type: "state.snapshot",
785
+ ...buildSnapshotPayload(freshSessionManager),
786
+ });
787
+ } else {
788
+ options.wsHub.broadcast({
789
+ type: "session.snapshot",
790
+ ...buildSnapshotPayload(freshSessionManager),
791
+ });
792
+ options.wsHub.broadcastSessions();
793
+ }
794
+ }
795
+
796
+ async function reloadSessionManager(
797
+ sessionManager: SessionManager,
798
+ options: Pick<GuiHttpRouteOptions, "sessionDir" | "cwd">,
799
+ ): Promise<SessionManager> {
800
+ const sessionFile = sessionManager.getSessionFile();
801
+ return sessionFile
802
+ ? SessionManager.open(sessionFile, options.sessionDir, options.cwd)
803
+ : sessionManager;
804
+ }
805
+
806
+ function buildSnapshotPayload(sessionManager: SessionManager): Record<string, unknown> {
807
+ const sessionId = sessionManager.getSessionId();
808
+ const entries = sessionManager.getEntries();
809
+ return {
810
+ sessionId,
811
+ state: projectDashboard(entries, sessionId, getSavedMarketStateSymbols()),
812
+ entries,
813
+ events: sessionEntriesToChatEvents(entries, {
814
+ sessionId,
815
+ title: sessionManager.getSessionName(),
816
+ }),
817
+ };
818
+ }
819
+
404
820
  export async function buildSessionBootstrapPayload(
405
821
  options: Pick<
406
822
  GuiHttpRouteOptions,
@@ -414,13 +830,18 @@ export async function buildSessionBootstrapPayload(
414
830
  return {
415
831
  role: roleForSessionBootstrap(options, sessionManager),
416
832
  sessionId,
833
+ coordination: {
834
+ sessionId,
835
+ status: roleForSessionBootstrap(options, sessionManager) === "writer" ? "ready" : "syncing",
836
+ ownerKind: ownerKindForSessionBootstrap(options, sessionManager),
837
+ },
417
838
  catalog: buildCatalog(),
418
839
  modelSetup: options.modelSetupController.buildCurrentModelSetupState(),
419
840
  askUserPrompts: Array.isArray(bootstrap.askUserPrompts) ? bootstrap.askUserPrompts : [],
420
841
  sessions: await SessionManager.list(options.cwd, options.sessionDir),
421
842
  snapshot: {
422
843
  sessionId,
423
- state: projectDashboard(entries, sessionId),
844
+ state: projectDashboard(entries, sessionId, getSavedMarketStateSymbols()),
424
845
  entries,
425
846
  events: sessionEntriesToChatEvents(entries, {
426
847
  sessionId,
@@ -430,6 +851,14 @@ export async function buildSessionBootstrapPayload(
430
851
  };
431
852
  }
432
853
 
854
+ function ownerKindForSessionBootstrap(
855
+ options: Pick<GuiHttpRouteOptions, "getSessionManager" | "role">,
856
+ sessionManager: SessionManager,
857
+ ): string | undefined {
858
+ if (roleForSessionBootstrap(options, sessionManager) === "writer") return "gui";
859
+ return readWriterLock(writerLockScopeForSession(sessionManager))?.processKind;
860
+ }
861
+
433
862
  function roleForSessionBootstrap(
434
863
  options: Pick<GuiHttpRouteOptions, "role" | "getSessionManager">,
435
864
  sessionManager: SessionManager,
@@ -462,6 +891,180 @@ export function sessionIdFromRoute(pathname: string, action: "bootstrap" | "runs
462
891
  }
463
892
  }
464
893
 
894
+ export function buildChatRunActionEnvelope(
895
+ body: Record<string, unknown>,
896
+ sessionId: string,
897
+ ): SessionActionEnvelope {
898
+ const actionId = String(body.actionId ?? "").trim();
899
+ const parsed = parseChatRunBody(body);
900
+ const attachments = parsed.ok ? parsed.value.attachments : [];
901
+ const images = parsed.ok ? parsed.value.images : [];
902
+ return {
903
+ sessionId,
904
+ actionId,
905
+ actionType: "chat.prompt",
906
+ payload: {
907
+ prompt: String(body.prompt ?? ""),
908
+ ...(images.length > 0 ? { imageCount: images.length } : {}),
909
+ ...(attachments.length > 0
910
+ ? { attachments: attachments.map((attachment) => ({ kind: attachment.kind })) }
911
+ : {}),
912
+ },
913
+ source: "browser",
914
+ };
915
+ }
916
+
917
+ export function parseChatRunBody(body: unknown): ChatRunParseResult {
918
+ const record = asRecord(body);
919
+ const prompt = String(record.prompt ?? "").trim();
920
+ if (!prompt) return { ok: false, error: "prompt is required" };
921
+
922
+ const rawImages = record.images;
923
+ const images: ChatRunImageInput[] = [];
924
+ if (rawImages !== undefined) {
925
+ if (!Array.isArray(rawImages)) return { ok: false, error: "images must be an array" };
926
+ if (rawImages.length > CHAT_RUN_MAX_IMAGE_COUNT) {
927
+ return { ok: false, error: "Attach up to 4 images" };
928
+ }
929
+ for (const rawImage of rawImages) {
930
+ const image = asRecord(rawImage);
931
+ const mimeType = String(image.mimeType ?? "");
932
+ const data = String(image.data ?? "");
933
+ if (!CHAT_RUN_IMAGE_MIME_TYPES.has(mimeType)) {
934
+ return { ok: false, error: "Unsupported image mime type" };
935
+ }
936
+ if (data.length > Math.ceil(CHAT_RUN_MAX_IMAGE_BYTES / 3) * 4) {
937
+ return { ok: false, error: "Image attachment must be 5 MB or smaller" };
938
+ }
939
+ const decoded = decodeStrictBase64(data);
940
+ if (!decoded) {
941
+ return { ok: false, error: "Image attachment data must be valid base64" };
942
+ }
943
+ if (decoded.byteLength > CHAT_RUN_MAX_IMAGE_BYTES) {
944
+ return { ok: false, error: "Image attachment must be 5 MB or smaller" };
945
+ }
946
+ images.push({ data, mimeType });
947
+ }
948
+ }
949
+
950
+ const rawAttachments = record.attachments;
951
+ const attachments: ChatRunAttachmentInput[] = [];
952
+ if (rawAttachments !== undefined) {
953
+ if (!Array.isArray(rawAttachments)) {
954
+ return { ok: false, error: "attachments must be an array" };
955
+ }
956
+ for (const rawAttachment of rawAttachments) {
957
+ const attachment = asRecord(rawAttachment);
958
+ const kind = String(attachment.kind ?? "");
959
+ const id = typeof attachment.id === "string" ? attachment.id.trim() : "";
960
+ if (kind === "portfolio") {
961
+ attachments.push(id ? { kind, id } : { kind });
962
+ } else if (kind === "watchlist" || kind === "report") {
963
+ if (!id) return { ok: false, error: `${kind} attachment id is required` };
964
+ attachments.push({ kind, id });
965
+ } else {
966
+ return { ok: false, error: "Unsupported attachment kind" };
967
+ }
968
+ }
969
+ }
970
+
971
+ if (prompt.startsWith("/") && (images.length > 0 || attachments.length > 0)) {
972
+ return { ok: false, error: "Attachments are not supported for slash commands" };
973
+ }
974
+
975
+ return { ok: true, value: { prompt, images, attachments } };
976
+ }
977
+
978
+ function decodeStrictBase64(data: string): Buffer | null {
979
+ if (!data || data.trim() !== data) return null;
980
+ if (data.length % 4 !== 0) return null;
981
+ if (/[^A-Za-z0-9+/=]/.test(data)) return null;
982
+ const firstPadding = data.indexOf("=");
983
+ if (firstPadding !== -1) {
984
+ const padding = data.slice(firstPadding);
985
+ if (!/^={1,2}$/.test(padding)) return null;
986
+ }
987
+ const decoded = Buffer.from(data, "base64");
988
+ if (decoded.byteLength === 0) return null;
989
+ return decoded.toString("base64") === data ? decoded : null;
990
+ }
991
+
992
+ export async function buildDispatchedPrompt(parsed: ParsedChatRunBody): Promise<string> {
993
+ if (parsed.prompt.startsWith("/")) return parsed.prompt;
994
+ if (parsed.attachments.length === 0) return parsed.prompt;
995
+ const db = initDefaultDatabase();
996
+ try {
997
+ const service = new MarketStateService(db);
998
+ const blocks = parsed.attachments.map((attachment) => {
999
+ const lines = attachmentSummaryLines(service, attachment);
1000
+ if (lines.length === 0) throw new Error(`${attachment.kind} attachment was empty`);
1001
+ return `[Attached by user — ${attachment.kind}]\n${lines.join("\n")}`;
1002
+ });
1003
+ return [parsed.prompt, ...blocks].join("\n\n");
1004
+ } finally {
1005
+ db.close();
1006
+ }
1007
+ }
1008
+
1009
+ function attachmentSummaryLines(
1010
+ service: MarketStateService,
1011
+ attachment: ChatRunAttachmentInput,
1012
+ ): string[] {
1013
+ if (attachment.kind === "portfolio") {
1014
+ const portfolioId = attachment.id ? Number(attachment.id) : undefined;
1015
+ if (attachment.id && !Number.isFinite(portfolioId)) {
1016
+ throw new Error("Unknown portfolio attachment");
1017
+ }
1018
+ return formatPortfolioSummary(service.listPortfolioLots(portfolioId));
1019
+ }
1020
+ if (attachment.kind === "watchlist") {
1021
+ const watchlistId =
1022
+ attachment.id === "default" ? service.getDefaultWatchlist().id : Number(attachment.id);
1023
+ if (!Number.isFinite(watchlistId)) throw new Error("Unknown watchlist attachment");
1024
+ return formatWatchlistSummary(service.listWatchlistItems(watchlistId));
1025
+ }
1026
+ const reports = service.listReportRuns();
1027
+ const report =
1028
+ attachment.id === "latest"
1029
+ ? reports[0]
1030
+ : reports.find((candidate) => candidate.id === Number(attachment.id));
1031
+ if (!report) throw new Error("Unknown report attachment");
1032
+ return formatLatestReportSummary(report);
1033
+ }
1034
+
1035
+ function attachmentLabel(attachment: ChatRunAttachmentInput): string {
1036
+ if (attachment.kind === "portfolio") return "Portfolio";
1037
+ if (attachment.kind === "watchlist") return "Watchlist";
1038
+ return "Latest report";
1039
+ }
1040
+
1041
+ function hasClientActionId(body: Record<string, unknown>): boolean {
1042
+ return typeof body.actionId === "string" && body.actionId.trim().length > 0;
1043
+ }
1044
+
1045
+ function requireSessionActionFields(
1046
+ res: ServerResponse,
1047
+ body: Record<string, unknown>,
1048
+ requireSessionId = true,
1049
+ ): boolean {
1050
+ if (requireSessionId && !String(body.sessionId ?? "").trim()) {
1051
+ writeJson(res, { error: "sessionId is required" }, 400);
1052
+ return false;
1053
+ }
1054
+ if (!String(body.actionId ?? "").trim()) {
1055
+ writeJson(res, { error: "actionId is required" }, 400);
1056
+ return false;
1057
+ }
1058
+ return true;
1059
+ }
1060
+
1061
+ function shouldBlockFailedCoordinatorAction(
1062
+ runSessionManager: SessionManager,
1063
+ body: Record<string, unknown>,
1064
+ ): boolean {
1065
+ return hasClientActionId(body) && shouldBlockFailedCoordinatorLockAction(runSessionManager);
1066
+ }
1067
+
465
1068
  async function handleTrustedGuiMutation(
466
1069
  req: IncomingMessage,
467
1070
  res: ServerResponse,
@@ -473,7 +1076,15 @@ async function handleTrustedGuiMutation(
473
1076
  writeJson(res, await options.wsHub.buildBootstrapPayload());
474
1077
  } catch (error) {
475
1078
  const message = error instanceof Error ? error.message : String(error);
476
- writeJson(res, { error: message }, message === "Read-only follower mode" ? 409 : 400);
1079
+ const coordinationError = message === "Read-only follower mode";
1080
+ writeJson(
1081
+ res,
1082
+ {
1083
+ error: coordinationError ? "OpenCandle is reconnecting to this session." : message,
1084
+ ...(coordinationError ? { code: "syncing" } : {}),
1085
+ },
1086
+ coordinationError ? 409 : 400,
1087
+ );
477
1088
  }
478
1089
  }
479
1090
 
@@ -531,6 +1142,19 @@ function allowTrustedGuiRequest(
531
1142
  return false;
532
1143
  }
533
1144
 
1145
+ function allowLocalCoordinatorRequest(
1146
+ req: IncomingMessage,
1147
+ res: ServerResponse,
1148
+ options: GuiHttpRouteOptions,
1149
+ ): boolean {
1150
+ if (req.headers["x-opencandle-coordinator-secret"] === options.localCoordinatorSecret) {
1151
+ return true;
1152
+ }
1153
+ res.writeHead(403, { "content-type": "application/json" });
1154
+ res.end(JSON.stringify({ error: "Local coordinator request was not authorized." }));
1155
+ return false;
1156
+ }
1157
+
534
1158
  function privateGuiHeaders(
535
1159
  contentTypeValue: string,
536
1160
  options: GuiHttpRouteOptions,