opencandle 0.9.0 → 0.11.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.
Files changed (98) hide show
  1. package/README.md +7 -5
  2. package/dist/analysts/orchestrator.d.ts +1 -4
  3. package/dist/analysts/orchestrator.js +15 -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/market-state/daily-report.d.ts +1 -1
  11. package/dist/market-state/daily-report.js +5 -16
  12. package/dist/market-state/daily-report.js.map +1 -1
  13. package/dist/market-state/service.d.ts +0 -35
  14. package/dist/market-state/service.js +0 -63
  15. package/dist/market-state/service.js.map +1 -1
  16. package/dist/memory/sqlite.js +20 -19
  17. package/dist/memory/sqlite.js.map +1 -1
  18. package/dist/pi/opencandle-extension.js +13 -221
  19. package/dist/pi/opencandle-extension.js.map +1 -1
  20. package/dist/pi/session-action-dedupe.d.ts +6 -0
  21. package/dist/pi/session-action-dedupe.js +126 -0
  22. package/dist/pi/session-action-dedupe.js.map +1 -0
  23. package/dist/pi/session-writer-lock.d.ts +26 -2
  24. package/dist/pi/session-writer-lock.js +230 -18
  25. package/dist/pi/session-writer-lock.js.map +1 -1
  26. package/dist/pi/setup.js +5 -5
  27. package/dist/pi/setup.js.map +1 -1
  28. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  29. package/dist/pi/tui-session-coordinator.js +283 -0
  30. package/dist/pi/tui-session-coordinator.js.map +1 -0
  31. package/dist/prompts/context-builder.js +1 -1
  32. package/dist/prompts/policy-cards.js +1 -1
  33. package/dist/prompts/policy-cards.js.map +1 -1
  34. package/dist/routing/classify-intent.js +4 -5
  35. package/dist/routing/classify-intent.js.map +1 -1
  36. package/dist/routing/route-manifest.js +0 -1
  37. package/dist/routing/route-manifest.js.map +1 -1
  38. package/dist/routing/router-prompt.js +1 -1
  39. package/dist/routing/router.js +35 -4
  40. package/dist/routing/router.js.map +1 -1
  41. package/dist/runtime/session-coordinator.js +2 -13
  42. package/dist/runtime/session-coordinator.js.map +1 -1
  43. package/dist/system-prompt.js +1 -1
  44. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  45. package/dist/tools/fundamentals/dcf.js +49 -6
  46. package/dist/tools/fundamentals/dcf.js.map +1 -1
  47. package/dist/tools/index.d.ts +0 -1
  48. package/dist/tools/index.js +0 -3
  49. package/dist/tools/index.js.map +1 -1
  50. package/dist/tools/portfolio/daily-report.js +10 -4
  51. package/dist/tools/portfolio/daily-report.js.map +1 -1
  52. package/dist/tools/technical/backtest.d.ts +23 -5
  53. package/dist/tools/technical/backtest.js +131 -94
  54. package/dist/tools/technical/backtest.js.map +1 -1
  55. package/gui/server/http-routes.ts +395 -19
  56. package/gui/server/invoke-tool.ts +164 -16
  57. package/gui/server/local-session-coordinator.ts +97 -0
  58. package/gui/server/market-state-api.ts +1 -43
  59. package/gui/server/server.ts +51 -6
  60. package/gui/server/session-actions.ts +117 -8
  61. package/gui/server/tool-metadata.ts +3 -1
  62. package/gui/server/ws-hub.ts +61 -6
  63. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +1 -0
  64. package/gui/web/dist/assets/index-BzyqyVnd.css +2 -0
  65. package/gui/web/dist/assets/index-DvMjkOP9.js +65 -0
  66. package/gui/web/dist/index.html +2 -2
  67. package/package.json +8 -5
  68. package/src/analysts/orchestrator.ts +15 -56
  69. package/src/cli-main.ts +253 -13
  70. package/src/config.ts +12 -20
  71. package/src/market-state/daily-report.ts +6 -16
  72. package/src/market-state/service.ts +0 -136
  73. package/src/memory/sqlite.ts +23 -19
  74. package/src/pi/opencandle-extension.ts +12 -265
  75. package/src/pi/session-action-dedupe.ts +155 -0
  76. package/src/pi/session-writer-lock.ts +290 -20
  77. package/src/pi/setup.ts +6 -6
  78. package/src/pi/tui-session-coordinator.ts +351 -0
  79. package/src/prompts/context-builder.ts +1 -1
  80. package/src/prompts/policy-cards.ts +1 -1
  81. package/src/routing/classify-intent.ts +4 -5
  82. package/src/routing/route-manifest.ts +0 -1
  83. package/src/routing/router-prompt.ts +1 -1
  84. package/src/routing/router.ts +39 -3
  85. package/src/runtime/session-coordinator.ts +2 -17
  86. package/src/system-prompt.ts +1 -1
  87. package/src/tools/AGENTS.md +1 -1
  88. package/src/tools/fundamentals/dcf.ts +57 -7
  89. package/src/tools/index.ts +0 -3
  90. package/src/tools/portfolio/daily-report.ts +10 -4
  91. package/src/tools/technical/backtest.ts +167 -108
  92. package/dist/tools/portfolio/predictions.d.ts +0 -55
  93. package/dist/tools/portfolio/predictions.js +0 -422
  94. package/dist/tools/portfolio/predictions.js.map +0 -1
  95. package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +0 -1
  96. package/gui/web/dist/assets/index-DdMnIvZ7.css +0 -2
  97. package/gui/web/dist/assets/index-YlMaahAS.js +0 -65
  98. package/src/tools/portfolio/predictions.ts +0 -553
@@ -4,10 +4,23 @@ 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
6
  import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
7
+ import {
8
+ clearPendingSessionAction,
9
+ hasAcceptedSessionAction,
10
+ hasPendingSessionAction,
11
+ recordAcceptedSessionAction,
12
+ recordPendingSessionAction,
13
+ } from "../../src/pi/session-action-dedupe.js";
7
14
  import type { ChatEvent } from "../shared/chat-events.js";
8
15
  import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
9
16
  import { chatRunSessionConflict } from "./chat-run-session.js";
17
+ import type { ToolInvokeController } from "./invoke-tool.js";
10
18
  import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
19
+ import type {
20
+ LocalSessionCoordinator,
21
+ SessionActionEnvelope,
22
+ SessionActionResult,
23
+ } from "./local-session-coordinator.js";
11
24
  import { buildMarketStateSnapshot, searchInstrumentCandidates } from "./market-state-api.js";
12
25
  import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
13
26
  import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
@@ -22,6 +35,7 @@ import {
22
35
  readWriterLock,
23
36
  refreshWriterLock,
24
37
  releaseWriterLock,
38
+ shouldBlockFailedCoordinatorAction as shouldBlockFailedCoordinatorLockAction,
25
39
  writerLockScopeForSession,
26
40
  } from "./writer-lock.js";
27
41
  import type { WsHub } from "./ws-hub.js";
@@ -35,14 +49,19 @@ interface GuiHttpRouteOptions {
35
49
  agentDir: string;
36
50
  sessionDir: string;
37
51
  privateApiSessionToken: string;
52
+ localCoordinatorEndpoint: string;
53
+ localCoordinatorSecret: string;
38
54
  allowRemotePrivateApi: boolean;
55
+ syncCurrentWriterLockScope?: () => void;
39
56
  getSession: () => AgentSession;
40
57
  getSessionManager: () => SessionManager;
41
58
  createSessionForManager: (sessionManager: SessionManager) => Promise<{ session: AgentSession }>;
42
59
  wsHub: WsHub;
43
60
  modelSetupController: ModelSetupController;
44
61
  sessionActionsController: SessionActionsController;
62
+ toolInvokeController: ToolInvokeController;
45
63
  quoteSnapshotStore: QuoteSnapshotStore;
64
+ localSessionCoordinator?: LocalSessionCoordinator;
46
65
  }
47
66
 
48
67
  export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
@@ -67,7 +86,11 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
67
86
  if (url.pathname === "/api/session/new" && req.method === "POST") {
68
87
  if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
69
88
  if (options.role !== "writer") {
70
- writeJson(res, { error: "Read-only follower mode" }, 409);
89
+ writeJson(
90
+ res,
91
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
92
+ 409,
93
+ );
71
94
  return;
72
95
  }
73
96
  await options.sessionActionsController.handleNewSession();
@@ -215,6 +238,84 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
215
238
  return;
216
239
  }
217
240
 
241
+ if (url.pathname === "/api/local-coordinator/chat-run" && req.method === "POST") {
242
+ if (!allowLocalCoordinatorRequest(req, res, options)) return;
243
+ const body = asRecord(await readJsonBody(req));
244
+ const requestedSessionId = String(body.sessionId ?? "").trim();
245
+ const sessionManager = requestedSessionId
246
+ ? await resolveSessionManagerById(options, requestedSessionId)
247
+ : undefined;
248
+ if (requestedSessionId && !sessionManager) {
249
+ writeJson(res, { error: "Unknown saved session" }, 404);
250
+ return;
251
+ }
252
+ await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body);
253
+ return;
254
+ }
255
+
256
+ if (url.pathname === "/api/local-coordinator/tool-invoke" && req.method === "POST") {
257
+ if (!allowLocalCoordinatorRequest(req, res, options)) return;
258
+ const body = asRecord(await readJsonBody(req));
259
+ let ack: unknown;
260
+ await options.toolInvokeController.handleToolInvokeMessage(
261
+ { send: (message) => (ack = message) },
262
+ {
263
+ requestId: "local-coordinator-tool",
264
+ actionId: String(body.actionId ?? ""),
265
+ sessionId: String(body.sessionId ?? ""),
266
+ toolName: String(body.toolName ?? ""),
267
+ args: asRecord(body.args),
268
+ allowProxy: false,
269
+ },
270
+ );
271
+ const message = asRecord(ack);
272
+ const result = asRecord(message.result);
273
+ if (result.toolCallId) {
274
+ writeJson(res, { result });
275
+ } else {
276
+ writeJson(
277
+ res,
278
+ { error: String(asRecord(message.error).message ?? "Tool invocation failed") },
279
+ 409,
280
+ );
281
+ }
282
+ return;
283
+ }
284
+
285
+ if (url.pathname === "/api/local-coordinator/ask-user" && req.method === "POST") {
286
+ if (!allowLocalCoordinatorRequest(req, res, options)) return;
287
+ const body = asRecord(await readJsonBody(req));
288
+ const payload = asRecord(body.payload);
289
+ try {
290
+ const action = {
291
+ actionId: String(body.actionId ?? ""),
292
+ sessionId: String(body.sessionId ?? ""),
293
+ source: "browser" as const,
294
+ allowProxy: false,
295
+ };
296
+ if (body.actionType === "ask_user.answer") {
297
+ await options.sessionActionsController.handleAskUserAnswer(
298
+ String(payload.id ?? ""),
299
+ payload.answer,
300
+ action,
301
+ );
302
+ } else if (body.actionType === "ask_user.cancel") {
303
+ await options.sessionActionsController.handleAskUserCancel(
304
+ String(payload.id ?? ""),
305
+ action,
306
+ );
307
+ } else {
308
+ writeJson(res, { error: "Unknown ask_user action" }, 400);
309
+ return;
310
+ }
311
+ writeJson(res, { ok: true });
312
+ } catch (error) {
313
+ const message = error instanceof Error ? error.message : String(error);
314
+ writeJson(res, { error: message }, 409);
315
+ }
316
+ return;
317
+ }
318
+
218
319
  const runSessionId = sessionIdFromRoute(url.pathname, "runs");
219
320
  if (runSessionId && req.method === "POST") {
220
321
  if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
@@ -237,13 +338,9 @@ async function handleSseChatRun(
237
338
  options: GuiHttpRouteOptions,
238
339
  activeRunSessionIds: Set<string>,
239
340
  targetSessionManager?: SessionManager,
341
+ bodyOverride?: Record<string, unknown>,
240
342
  ): 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);
343
+ const body = bodyOverride ?? asRecord(await readJsonBody(req));
247
344
  const prompt = String(asRecord(body).prompt ?? "").trim();
248
345
  if (!prompt) {
249
346
  writeJson(res, { error: "prompt is required" }, 400);
@@ -251,7 +348,8 @@ async function handleSseChatRun(
251
348
  }
252
349
 
253
350
  const currentSessionManager = options.getSessionManager();
254
- const requestedSessionId = String(asRecord(body).sessionId ?? "").trim();
351
+ const bodyRecord = asRecord(body);
352
+ const requestedSessionId = String(bodyRecord.sessionId ?? "").trim();
255
353
  const runSessionManager = targetSessionManager ?? currentSessionManager;
256
354
  const sessionId = runSessionManager.getSessionId();
257
355
  if (!targetSessionManager) {
@@ -269,9 +367,153 @@ async function handleSseChatRun(
269
367
  return;
270
368
  }
271
369
 
370
+ const proxyAllowed = bodyOverride === undefined;
371
+ const actionId = String(bodyRecord.actionId ?? "").trim();
372
+ const shouldProxyChatRun = proxyAllowed && canProxyChatRunToCoordinator(runSessionManager);
373
+ if (shouldProxyChatRun) {
374
+ if (await proxyChatRunToCoordinator(res, runSessionManager, bodyRecord)) {
375
+ const useCurrentSession =
376
+ !targetSessionManager ||
377
+ currentSessionManager.getSessionFile() === runSessionManager.getSessionFile();
378
+ await broadcastFreshRunSessionSnapshot(options, runSessionManager, useCurrentSession);
379
+ return;
380
+ }
381
+ if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
382
+ writeJson(res, { ok: true, duplicate: true });
383
+ return;
384
+ }
385
+ if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
386
+ writeJson(
387
+ res,
388
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
389
+ 409,
390
+ );
391
+ return;
392
+ }
393
+ if (shouldBlockFailedCoordinatorAction(runSessionManager, bodyRecord)) {
394
+ writeJson(
395
+ res,
396
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
397
+ 409,
398
+ );
399
+ return;
400
+ }
401
+ }
402
+ if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
403
+ writeJson(res, { ok: true, duplicate: true });
404
+ return;
405
+ }
406
+ if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
407
+ writeJson(res, { error: "OpenCandle is reconnecting to this session.", code: "syncing" }, 409);
408
+ return;
409
+ }
410
+
411
+ if (options.localSessionCoordinator) {
412
+ const action = buildChatRunActionEnvelope(bodyRecord, sessionId);
413
+ let result: SessionActionResult<{ streamed: boolean }>;
414
+ try {
415
+ result = await options.localSessionCoordinator.runSessionAction(action, async () => {
416
+ const admitted = await streamAcceptedSseChatRun({
417
+ res,
418
+ options,
419
+ activeRunSessionIds,
420
+ targetSessionManager,
421
+ currentSessionManager,
422
+ runSessionManager,
423
+ sessionId,
424
+ prompt,
425
+ actionId: action.actionId,
426
+ });
427
+ if (!admitted) throw new SessionActionNotAdmitted();
428
+ return { streamed: true };
429
+ });
430
+ } catch (error) {
431
+ if (error instanceof SessionActionNotAdmitted) return;
432
+ throw error;
433
+ }
434
+ if (!result.ok) {
435
+ writeJson(res, { error: result.message, code: result.code }, 409);
436
+ return;
437
+ }
438
+ if (result.duplicate && !res.headersSent) {
439
+ writeJson(res, { ok: true, duplicate: true });
440
+ }
441
+ return;
442
+ }
443
+
444
+ await streamAcceptedSseChatRun({
445
+ res,
446
+ options,
447
+ activeRunSessionIds,
448
+ targetSessionManager,
449
+ currentSessionManager,
450
+ runSessionManager,
451
+ sessionId,
452
+ prompt,
453
+ actionId,
454
+ });
455
+ }
456
+
457
+ export function canProxyChatRunToCoordinator(runSessionManager: SessionManager): boolean {
458
+ const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
459
+ return Boolean(lock?.coordinatorEndpoint && lock.coordinatorSecret && lock.pid !== process.pid);
460
+ }
461
+
462
+ async function proxyChatRunToCoordinator(
463
+ res: ServerResponse,
464
+ runSessionManager: SessionManager,
465
+ body: Record<string, unknown>,
466
+ ): Promise<boolean> {
467
+ const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
468
+ if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret) return false;
469
+ const endpoint = new URL("/api/local-coordinator/chat-run", lock.coordinatorEndpoint);
470
+ let response: Response;
471
+ try {
472
+ response = await fetch(endpoint, {
473
+ method: "POST",
474
+ headers: {
475
+ "content-type": "application/json",
476
+ "x-opencandle-coordinator-secret": lock.coordinatorSecret,
477
+ },
478
+ body: JSON.stringify({ ...body, sessionId: runSessionManager.getSessionId() }),
479
+ });
480
+ } catch {
481
+ return false;
482
+ }
483
+ res.writeHead(response.status, Object.fromEntries(response.headers));
484
+ if (response.body) {
485
+ for await (const chunk of response.body) {
486
+ res.write(chunk);
487
+ }
488
+ }
489
+ res.end();
490
+ return true;
491
+ }
492
+
493
+ async function streamAcceptedSseChatRun({
494
+ res,
495
+ options,
496
+ activeRunSessionIds,
497
+ targetSessionManager,
498
+ currentSessionManager,
499
+ runSessionManager,
500
+ sessionId,
501
+ prompt,
502
+ actionId,
503
+ }: {
504
+ res: ServerResponse;
505
+ options: GuiHttpRouteOptions;
506
+ activeRunSessionIds: Set<string>;
507
+ targetSessionManager?: SessionManager;
508
+ currentSessionManager: SessionManager;
509
+ runSessionManager: SessionManager;
510
+ sessionId: string;
511
+ prompt: string;
512
+ actionId: string;
513
+ }): Promise<boolean> {
272
514
  if (activeRunSessionIds.has(sessionId)) {
273
515
  writeJson(res, { error: "Session already has an active run", code: "session_busy" }, 409);
274
- return;
516
+ return false;
275
517
  }
276
518
 
277
519
  const currentSessionFile = currentSessionManager.getSessionFile();
@@ -279,12 +521,20 @@ async function handleSseChatRun(
279
521
  const useCurrentSession = !targetSessionManager || currentSessionFile === targetSessionFile;
280
522
  let acquiredLockScope = "";
281
523
  let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
282
- if (!useCurrentSession) {
524
+ const needsWriterLock = !useCurrentSession || options.role !== "writer";
525
+ if (needsWriterLock) {
283
526
  const lockScope = writerLockScopeForSession(runSessionManager);
284
- const lockResult = await acquireWriterLock(lockScope, "gui");
527
+ const lockResult = await acquireWriterLock(lockScope, "gui", {
528
+ coordinatorEndpoint: options.localCoordinatorEndpoint,
529
+ coordinatorSecret: options.localCoordinatorSecret,
530
+ });
285
531
  if (lockResult.role !== "writer") {
286
- writeJson(res, { error: "Read-only follower mode", lock: lockResult.lock }, 409);
287
- return;
532
+ writeJson(
533
+ res,
534
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
535
+ 409,
536
+ );
537
+ return false;
288
538
  }
289
539
  acquiredLockScope = lockScope;
290
540
  lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
@@ -302,7 +552,7 @@ async function handleSseChatRun(
302
552
  if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
303
553
  const message = error instanceof Error ? error.message : String(error);
304
554
  writeJson(res, { error: message }, 500);
305
- return;
555
+ return false;
306
556
  }
307
557
 
308
558
  res.writeHead(200, {
@@ -331,15 +581,31 @@ async function handleSseChatRun(
331
581
  originalPrompt: prompt,
332
582
  });
333
583
  const observation = createPromptObservation();
584
+ let actionAccepted = false;
585
+ const recordAcceptedAction = () => {
586
+ if (actionAccepted) return;
587
+ recordAcceptedSessionAction(runSessionManager, actionId);
588
+ if (useCurrentSession) options.syncCurrentWriterLockScope?.();
589
+ actionAccepted = true;
590
+ };
334
591
  const unsubscribeLive = runSession.subscribe((event) => {
335
592
  liveAdapter.handle(event);
336
593
  observePromptEvent(observation, event);
594
+ if (
595
+ !prompt.startsWith("/") &&
596
+ !actionAccepted &&
597
+ observation.userTexts.some((text) => text.trim() === prompt.trim())
598
+ ) {
599
+ recordAcceptedAction();
600
+ }
337
601
  });
338
602
 
339
603
  try {
604
+ recordPendingSessionAction(runSessionManager, actionId);
340
605
  const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
341
606
  if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
342
607
  runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
608
+ recordAcceptedAction();
343
609
  const message =
344
610
  modelSetup.requirement === "select_model"
345
611
  ? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
@@ -348,10 +614,11 @@ async function handleSseChatRun(
348
614
  source: "gui",
349
615
  requirement: modelSetup.requirement,
350
616
  });
351
- broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
617
+ await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
352
618
  } else {
353
619
  await promptAndSettle(runSession, prompt, beforeIds, observation);
354
- broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
620
+ recordAcceptedAction();
621
+ await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
355
622
  }
356
623
  seq = liveAdapter.nextSeq();
357
624
  if (seq === liveStartSeq) {
@@ -375,6 +642,7 @@ async function handleSseChatRun(
375
642
  }
376
643
  writeSse(res, { type: "run.completed", runId, sessionId, seq });
377
644
  } catch (error) {
645
+ if (!actionAccepted) clearPendingSessionAction(runSessionManager, actionId);
378
646
  seq = liveAdapter.nextSeq();
379
647
  const message = error instanceof Error ? error.message : String(error);
380
648
  writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
@@ -386,14 +654,18 @@ async function handleSseChatRun(
386
654
  if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
387
655
  res.end();
388
656
  }
657
+ return actionAccepted;
389
658
  }
390
659
 
391
- function broadcastRunSessionSnapshot(
660
+ class SessionActionNotAdmitted extends Error {}
661
+
662
+ async function broadcastRunSessionSnapshot(
392
663
  options: GuiHttpRouteOptions,
393
664
  sessionManager: SessionManager,
394
665
  useCurrentSession: boolean,
395
- ): void {
666
+ ): Promise<void> {
396
667
  if (useCurrentSession) {
668
+ options.syncCurrentWriterLockScope?.();
397
669
  options.wsHub.broadcastState();
398
670
  } else {
399
671
  options.wsHub.broadcastSessionSnapshot(sessionManager);
@@ -401,6 +673,51 @@ function broadcastRunSessionSnapshot(
401
673
  }
402
674
  }
403
675
 
676
+ async function broadcastFreshRunSessionSnapshot(
677
+ options: GuiHttpRouteOptions,
678
+ sessionManager: SessionManager,
679
+ useCurrentSession: boolean,
680
+ ): Promise<void> {
681
+ const freshSessionManager = await reloadSessionManager(sessionManager, options);
682
+ if (useCurrentSession) {
683
+ options.syncCurrentWriterLockScope?.();
684
+ options.wsHub.broadcast({
685
+ type: "state.snapshot",
686
+ ...buildSnapshotPayload(freshSessionManager),
687
+ });
688
+ } else {
689
+ options.wsHub.broadcast({
690
+ type: "session.snapshot",
691
+ ...buildSnapshotPayload(freshSessionManager),
692
+ });
693
+ options.wsHub.broadcastSessions();
694
+ }
695
+ }
696
+
697
+ async function reloadSessionManager(
698
+ sessionManager: SessionManager,
699
+ options: Pick<GuiHttpRouteOptions, "sessionDir" | "cwd">,
700
+ ): Promise<SessionManager> {
701
+ const sessionFile = sessionManager.getSessionFile();
702
+ return sessionFile
703
+ ? SessionManager.open(sessionFile, options.sessionDir, options.cwd)
704
+ : sessionManager;
705
+ }
706
+
707
+ function buildSnapshotPayload(sessionManager: SessionManager): Record<string, unknown> {
708
+ const sessionId = sessionManager.getSessionId();
709
+ const entries = sessionManager.getEntries();
710
+ return {
711
+ sessionId,
712
+ state: projectDashboard(entries, sessionId),
713
+ entries,
714
+ events: sessionEntriesToChatEvents(entries, {
715
+ sessionId,
716
+ title: sessionManager.getSessionName(),
717
+ }),
718
+ };
719
+ }
720
+
404
721
  export async function buildSessionBootstrapPayload(
405
722
  options: Pick<
406
723
  GuiHttpRouteOptions,
@@ -414,6 +731,11 @@ export async function buildSessionBootstrapPayload(
414
731
  return {
415
732
  role: roleForSessionBootstrap(options, sessionManager),
416
733
  sessionId,
734
+ coordination: {
735
+ sessionId,
736
+ status: roleForSessionBootstrap(options, sessionManager) === "writer" ? "ready" : "syncing",
737
+ ownerKind: ownerKindForSessionBootstrap(options, sessionManager),
738
+ },
417
739
  catalog: buildCatalog(),
418
740
  modelSetup: options.modelSetupController.buildCurrentModelSetupState(),
419
741
  askUserPrompts: Array.isArray(bootstrap.askUserPrompts) ? bootstrap.askUserPrompts : [],
@@ -430,6 +752,14 @@ export async function buildSessionBootstrapPayload(
430
752
  };
431
753
  }
432
754
 
755
+ function ownerKindForSessionBootstrap(
756
+ options: Pick<GuiHttpRouteOptions, "getSessionManager" | "role">,
757
+ sessionManager: SessionManager,
758
+ ): string | undefined {
759
+ if (roleForSessionBootstrap(options, sessionManager) === "writer") return "gui";
760
+ return readWriterLock(writerLockScopeForSession(sessionManager))?.processKind;
761
+ }
762
+
433
763
  function roleForSessionBootstrap(
434
764
  options: Pick<GuiHttpRouteOptions, "role" | "getSessionManager">,
435
765
  sessionManager: SessionManager,
@@ -462,6 +792,31 @@ export function sessionIdFromRoute(pathname: string, action: "bootstrap" | "runs
462
792
  }
463
793
  }
464
794
 
795
+ export function buildChatRunActionEnvelope(
796
+ body: Record<string, unknown>,
797
+ sessionId: string,
798
+ ): SessionActionEnvelope {
799
+ const actionId = String(body.actionId ?? "").trim() || `legacy-chat-${Date.now()}`;
800
+ return {
801
+ sessionId,
802
+ actionId,
803
+ actionType: "chat.prompt",
804
+ payload: { prompt: String(body.prompt ?? "") },
805
+ source: "browser",
806
+ };
807
+ }
808
+
809
+ function hasClientActionId(body: Record<string, unknown>): boolean {
810
+ return typeof body.actionId === "string" && body.actionId.trim().length > 0;
811
+ }
812
+
813
+ function shouldBlockFailedCoordinatorAction(
814
+ runSessionManager: SessionManager,
815
+ body: Record<string, unknown>,
816
+ ): boolean {
817
+ return hasClientActionId(body) && shouldBlockFailedCoordinatorLockAction(runSessionManager);
818
+ }
819
+
465
820
  async function handleTrustedGuiMutation(
466
821
  req: IncomingMessage,
467
822
  res: ServerResponse,
@@ -473,7 +828,15 @@ async function handleTrustedGuiMutation(
473
828
  writeJson(res, await options.wsHub.buildBootstrapPayload());
474
829
  } catch (error) {
475
830
  const message = error instanceof Error ? error.message : String(error);
476
- writeJson(res, { error: message }, message === "Read-only follower mode" ? 409 : 400);
831
+ const coordinationError = message === "Read-only follower mode";
832
+ writeJson(
833
+ res,
834
+ {
835
+ error: coordinationError ? "OpenCandle is reconnecting to this session." : message,
836
+ ...(coordinationError ? { code: "syncing" } : {}),
837
+ },
838
+ coordinationError ? 409 : 400,
839
+ );
477
840
  }
478
841
  }
479
842
 
@@ -531,6 +894,19 @@ function allowTrustedGuiRequest(
531
894
  return false;
532
895
  }
533
896
 
897
+ function allowLocalCoordinatorRequest(
898
+ req: IncomingMessage,
899
+ res: ServerResponse,
900
+ options: GuiHttpRouteOptions,
901
+ ): boolean {
902
+ if (req.headers["x-opencandle-coordinator-secret"] === options.localCoordinatorSecret) {
903
+ return true;
904
+ }
905
+ res.writeHead(403, { "content-type": "application/json" });
906
+ res.end(JSON.stringify({ error: "Local coordinator request was not authorized." }));
907
+ return false;
908
+ }
909
+
534
910
  function privateGuiHeaders(
535
911
  contentTypeValue: string,
536
912
  options: GuiHttpRouteOptions,