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,14 +4,24 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import type { TSchema } from "@sinclair/typebox";
5
5
  import { Value } from "@sinclair/typebox/value";
6
6
  import { getDefaults } from "../../src/memory/tool-defaults.js";
7
+ import {
8
+ clearPendingSessionAction,
9
+ hasAcceptedSessionAction,
10
+ hasPendingSessionAction,
11
+ recordAcceptedSessionAction,
12
+ recordPendingSessionAction,
13
+ } from "../../src/pi/session-action-dedupe.js";
7
14
  import { wrapWithDefaults } from "../../src/runtime/tool-defaults-wrapper.js";
8
15
  import { getAllTools } from "../../src/tools/index.js";
9
16
  import type { AskUserHandler } from "../../src/types/index.js";
17
+ import type { LocalSessionCoordinator } from "./local-session-coordinator.js";
10
18
  import { buildToolInvokeAckMessage } from "./tool-invoke-ack.js";
11
19
  import {
12
20
  acquireWriterLock,
21
+ readWriterLock,
13
22
  refreshWriterLock,
14
23
  releaseWriterLock,
24
+ shouldBlockFailedCoordinatorAction,
15
25
  writerLockScopeForSession,
16
26
  } from "./writer-lock.js";
17
27
 
@@ -30,6 +40,7 @@ export interface ToolInvokeController {
30
40
  toolName: string,
31
41
  args: Record<string, unknown>,
32
42
  sessionId?: string,
43
+ options?: { actionId?: string; allowProxy?: boolean },
33
44
  ): Promise<InvokeToolResult>;
34
45
  handleToolInvokeMessage(client: ToolInvokeClient, data: Record<string, unknown>): Promise<void>;
35
46
  }
@@ -46,6 +57,10 @@ export interface ToolInvokeControllerOptions {
46
57
  resolveSessionManager?: (sessionId: string) => Promise<SessionManager | null>;
47
58
  broadcastSessionSnapshot?: (sessionManager: SessionManager) => void;
48
59
  broadcastSessions?: () => void;
60
+ localSessionCoordinator?: LocalSessionCoordinator;
61
+ localCoordinatorEndpoint?: string;
62
+ localCoordinatorSecret?: string;
63
+ syncWriterLockScope?: () => void;
49
64
  }
50
65
 
51
66
  export function createToolInvokeController({
@@ -60,13 +75,17 @@ export function createToolInvokeController({
60
75
  resolveSessionManager,
61
76
  broadcastSessionSnapshot,
62
77
  broadcastSessions,
78
+ localSessionCoordinator,
79
+ localCoordinatorEndpoint,
80
+ localCoordinatorSecret,
81
+ syncWriterLockScope,
63
82
  }: ToolInvokeControllerOptions): ToolInvokeController {
64
83
  async function handleToolInvoke(
65
84
  toolName: string,
66
85
  args: Record<string, unknown>,
67
86
  sessionId = "",
87
+ options: { actionId?: string; allowProxy?: boolean } = {},
68
88
  ): Promise<InvokeToolResult> {
69
- if (role !== "writer") throw new Error("Read-only follower mode");
70
89
  const tool = getTools().find((candidate) => candidate.name === toolName);
71
90
  if (!tool) throw new Error(`Unknown tool: ${toolName}`);
72
91
 
@@ -77,15 +96,50 @@ export function createToolInvokeController({
77
96
  resolveSessionManager,
78
97
  );
79
98
  const useCurrentSession = sameSessionStorage(currentSessionManager, runSessionManager);
99
+ const allowProxy = options.allowProxy !== false;
100
+ const shouldProxyToolInvoke =
101
+ allowProxy &&
102
+ (role !== "writer" || !useCurrentSession) &&
103
+ canProxyToolInvokeToCoordinator(runSessionManager);
104
+ if (shouldProxyToolInvoke) {
105
+ const proxied = await proxyToolInvokeToCoordinator(
106
+ runSessionManager,
107
+ toolName,
108
+ args,
109
+ options.actionId,
110
+ );
111
+ if (proxied) return proxied;
112
+ if (options.actionId && hasAcceptedSessionAction(runSessionManager, options.actionId)) {
113
+ throw new Error("OpenCandle already accepted this action in the active session.");
114
+ }
115
+ if (options.actionId && hasPendingSessionAction(runSessionManager, options.actionId)) {
116
+ throw new Error("OpenCandle is reconnecting to this session.");
117
+ }
118
+ if (options.actionId && shouldBlockFailedCoordinatorAction(runSessionManager)) {
119
+ throw new Error("OpenCandle is reconnecting to this session.");
120
+ }
121
+ }
122
+ if (options.actionId && hasAcceptedSessionAction(runSessionManager, options.actionId)) {
123
+ throw new Error("OpenCandle already accepted this action in the active session.");
124
+ }
125
+ if (options.actionId && hasPendingSessionAction(runSessionManager, options.actionId)) {
126
+ throw new Error("OpenCandle is reconnecting to this session.");
127
+ }
80
128
  let acquiredLockScope = "";
81
129
  let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
82
- if (!useCurrentSession) {
130
+ const needsWriterLock = !useCurrentSession || role !== "writer";
131
+ // The writer fast path must fail closed like chat runs when this process
132
+ // lost its lock scope migration, or it can double-write to a session
133
+ // another process now owns.
134
+ if (!needsWriterLock) syncWriterLockScope?.();
135
+ if (needsWriterLock) {
83
136
  const lockScope = writerLockScopeForSession(runSessionManager);
84
- const lockResult = await acquireWriterLock(lockScope, "gui");
137
+ const lockResult = await acquireWriterLock(lockScope, "gui", {
138
+ coordinatorEndpoint: localCoordinatorEndpoint,
139
+ coordinatorSecret: localCoordinatorSecret,
140
+ });
85
141
  if (lockResult.role !== "writer") {
86
- throw new Error(
87
- `Session is currently being written by ${lockResult.lock.processKind} (pid ${lockResult.lock.pid}).`,
88
- );
142
+ throw new Error("OpenCandle is reconnecting to this session.");
89
143
  }
90
144
  acquiredLockScope = lockScope;
91
145
  lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
@@ -93,12 +147,27 @@ export function createToolInvokeController({
93
147
 
94
148
  try {
95
149
  const runSessionId = safeSessionId(runSessionManager);
96
- const result = await invokeTool(runSessionManager, tool, args, "ui", {
97
- askUserHandler:
98
- runSessionId && askUserHandlerForSessionId
99
- ? askUserHandlerForSessionId(runSessionId)
100
- : askUserHandler,
101
- });
150
+ recordPendingSessionAction(runSessionManager, options.actionId ?? "");
151
+ let actionAccepted = false;
152
+ const recordAcceptedAction = () => {
153
+ if (actionAccepted) return;
154
+ recordAcceptedSessionAction(runSessionManager, options.actionId ?? "");
155
+ actionAccepted = true;
156
+ };
157
+ let result: InvokeToolResult;
158
+ try {
159
+ result = await invokeTool(runSessionManager, tool, args, "ui", {
160
+ askUserHandler:
161
+ runSessionId && askUserHandlerForSessionId
162
+ ? askUserHandlerForSessionId(runSessionId)
163
+ : askUserHandler,
164
+ onTranscriptStarted: recordAcceptedAction,
165
+ });
166
+ } catch (error) {
167
+ if (!actionAccepted) clearPendingSessionAction(runSessionManager, options.actionId ?? "");
168
+ throw error;
169
+ }
170
+ recordAcceptedAction();
102
171
  if (!result.isError && marketStateToolMapping(toolName) != null) {
103
172
  onMarketStateChanged?.();
104
173
  }
@@ -120,10 +189,28 @@ export function createToolInvokeController({
120
189
  data: Record<string, unknown>,
121
190
  ): Promise<void> {
122
191
  const requestId = typeof data.requestId === "string" ? data.requestId : "";
192
+ const actionId = typeof data.actionId === "string" ? data.actionId : "";
123
193
  const toolName = String(data.toolName ?? "");
124
194
  const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
195
+ const allowProxy = data.allowProxy !== false;
125
196
  try {
126
- const result = await handleToolInvoke(toolName, requestArgs(data.args), sessionId);
197
+ const invoke = () =>
198
+ handleToolInvoke(toolName, requestArgs(data.args), sessionId, { actionId, allowProxy });
199
+ const actionResult =
200
+ localSessionCoordinator && actionId
201
+ ? await localSessionCoordinator.runSessionAction(
202
+ {
203
+ sessionId: sessionId || safeSessionId(getSessionManager()),
204
+ actionId,
205
+ actionType: "tool.invoke",
206
+ payload: { toolName, args: requestArgs(data.args) },
207
+ source: "browser",
208
+ },
209
+ invoke,
210
+ )
211
+ : await invoke().then((result) => ({ ok: true as const, duplicate: false, result }));
212
+ if (!actionResult.ok) throw new Error(actionResult.message);
213
+ const result = actionResult.result;
127
214
  if (requestId) {
128
215
  client.send(buildToolInvokeAckMessage(requestId, toolName, result));
129
216
  }
@@ -146,6 +233,64 @@ export function createToolInvokeController({
146
233
  return { handleToolInvoke, handleToolInvokeMessage };
147
234
  }
148
235
 
236
+ export function canProxyToolInvokeToCoordinator(runSessionManager: SessionManager): boolean {
237
+ let lockScope: string;
238
+ try {
239
+ lockScope = writerLockScopeForSession(runSessionManager);
240
+ } catch {
241
+ return false;
242
+ }
243
+ const lock = readWriterLock(lockScope);
244
+ return Boolean(
245
+ lock?.processKind === "gui" &&
246
+ lock.coordinatorEndpoint &&
247
+ lock.coordinatorSecret &&
248
+ lock.pid !== process.pid,
249
+ );
250
+ }
251
+
252
+ async function proxyToolInvokeToCoordinator(
253
+ runSessionManager: SessionManager,
254
+ toolName: string,
255
+ args: Record<string, unknown>,
256
+ actionId = "",
257
+ ): Promise<InvokeToolResult | null> {
258
+ const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
259
+ if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret) return null;
260
+ const endpoint = new URL("/api/local-coordinator/tool-invoke", lock.coordinatorEndpoint);
261
+ let response: Response;
262
+ try {
263
+ response = await fetch(endpoint, {
264
+ method: "POST",
265
+ headers: {
266
+ "content-type": "application/json",
267
+ "x-opencandle-coordinator-secret": lock.coordinatorSecret,
268
+ },
269
+ body: JSON.stringify({
270
+ sessionId: safeSessionId(runSessionManager),
271
+ actionId,
272
+ toolName,
273
+ args,
274
+ }),
275
+ });
276
+ } catch {
277
+ return null;
278
+ }
279
+ const body = asRecord(await response.json());
280
+ const ackResult = asRecord(body.result);
281
+ if (!response.ok || !ackResult.toolCallId) {
282
+ throw new Error(String(body.error ?? "OpenCandle is reconnecting to this session."));
283
+ }
284
+ return {
285
+ toolCallId: String(ackResult.toolCallId),
286
+ result: {
287
+ content: Array.isArray(ackResult.content) ? ackResult.content : [],
288
+ details: ackResult.details,
289
+ },
290
+ isError: Boolean(ackResult.isError),
291
+ };
292
+ }
293
+
149
294
  function sameSessionStorage(current: SessionManager, target: SessionManager): boolean {
150
295
  if (current === target) return true;
151
296
  const currentFile = safeSessionFile(current);
@@ -192,7 +337,11 @@ export async function invokeToolFromUi(
192
337
  tool: AgentTool<TSchema, unknown>,
193
338
  args: Record<string, unknown>,
194
339
  source: "ui" | "background" = "ui",
195
- options: { askUserHandler?: AskUserHandler; recordTranscript?: boolean } = {},
340
+ options: {
341
+ askUserHandler?: AskUserHandler;
342
+ recordTranscript?: boolean;
343
+ onTranscriptStarted?: () => void;
344
+ } = {},
196
345
  ): Promise<InvokeToolResult> {
197
346
  if (!Value.Check(tool.parameters, args)) {
198
347
  const errors = [...Value.Errors(tool.parameters, args)]
@@ -222,6 +371,7 @@ export async function invokeToolFromUi(
222
371
  const recordTranscript = options.recordTranscript ?? true;
223
372
  if (recordTranscript) {
224
373
  sessionManager.appendMessage(assistant);
374
+ options.onTranscriptStarted?.();
225
375
  }
226
376
 
227
377
  const wrapped = wrapWithDefaults(tool, getDefaults(tool.name));
@@ -299,8 +449,6 @@ function marketStateToolMapping(toolName: string): { domain: string; targetType:
299
449
  return { domain: "watchlist", targetType: "watchlist_item" };
300
450
  case "track_portfolio":
301
451
  return { domain: "portfolio", targetType: "portfolio_lot" };
302
- case "track_prediction":
303
- return { domain: "predictions", targetType: "prediction" };
304
452
  case "manage_alerts":
305
453
  return { domain: "alerts", targetType: "alert_rule" };
306
454
  case "daily_watchlist_report":
@@ -0,0 +1,97 @@
1
+ export type SessionActionType =
2
+ | "chat.prompt"
3
+ | "tool.invoke"
4
+ | "ask_user.answer"
5
+ | "ask_user.cancel"
6
+ | "run.cancel";
7
+
8
+ export type SessionActionSource = "gui" | "tui" | "browser";
9
+
10
+ export interface SessionActionEnvelope {
11
+ sessionId: string;
12
+ actionId: string;
13
+ actionType: SessionActionType;
14
+ payload: Record<string, unknown>;
15
+ source: SessionActionSource;
16
+ }
17
+
18
+ export type SessionActionResult<T = unknown> =
19
+ | { ok: true; duplicate: boolean; result: T }
20
+ | { ok: false; code: "session_busy"; message: string };
21
+
22
+ export interface LocalSessionCoordinator {
23
+ runSessionAction<T>(
24
+ action: SessionActionEnvelope,
25
+ handler: (action: SessionActionEnvelope) => Promise<T>,
26
+ ): Promise<SessionActionResult<T>>;
27
+ }
28
+
29
+ export interface LocalSessionCoordinatorOptions {
30
+ dedupeRetentionMs?: number;
31
+ now?: () => number;
32
+ }
33
+
34
+ interface AcceptedAction {
35
+ expiresAt: number;
36
+ result: unknown;
37
+ }
38
+
39
+ const DEFAULT_DEDUPE_RETENTION_MS = 10 * 60 * 1000;
40
+ const BUSY_MESSAGE = "OpenCandle is still working in this session. Try again when it finishes.";
41
+
42
+ export function createLocalSessionCoordinator(
43
+ options: LocalSessionCoordinatorOptions = {},
44
+ ): LocalSessionCoordinator {
45
+ const now = options.now ?? Date.now;
46
+ const dedupeRetentionMs = options.dedupeRetentionMs ?? DEFAULT_DEDUPE_RETENTION_MS;
47
+ const acceptedActions = new Map<string, AcceptedAction>();
48
+ const activeActions = new Map<string, Promise<unknown>>();
49
+ const activeRunSessions = new Set<string>();
50
+
51
+ async function runSessionAction<T>(
52
+ action: SessionActionEnvelope,
53
+ handler: (action: SessionActionEnvelope) => Promise<T>,
54
+ ): Promise<SessionActionResult<T>> {
55
+ pruneExpiredActions();
56
+ const actionKey = `${action.sessionId}:${action.actionId}`;
57
+ const accepted = acceptedActions.get(actionKey);
58
+ if (accepted) return { ok: true, duplicate: true, result: accepted.result as T };
59
+ const active = activeActions.get(actionKey);
60
+ if (active) return { ok: true, duplicate: true, result: (await active) as T };
61
+
62
+ if (isRunAdmissionAction(action) && activeRunSessions.has(action.sessionId)) {
63
+ return { ok: false, code: "session_busy", message: BUSY_MESSAGE };
64
+ }
65
+
66
+ if (isRunAdmissionAction(action)) activeRunSessions.add(action.sessionId);
67
+ const runAction = (async () => {
68
+ const result = await handler(action);
69
+ acceptedActions.set(actionKey, {
70
+ expiresAt: now() + dedupeRetentionMs,
71
+ result,
72
+ });
73
+ return result;
74
+ })();
75
+ activeActions.set(actionKey, runAction);
76
+ try {
77
+ const result = await runAction;
78
+ return { ok: true, duplicate: false, result };
79
+ } finally {
80
+ activeActions.delete(actionKey);
81
+ if (isRunAdmissionAction(action)) activeRunSessions.delete(action.sessionId);
82
+ }
83
+ }
84
+
85
+ function pruneExpiredActions(): void {
86
+ const currentTime = now();
87
+ for (const [actionKey, accepted] of acceptedActions) {
88
+ if (accepted.expiresAt <= currentTime) acceptedActions.delete(actionKey);
89
+ }
90
+ }
91
+
92
+ return { runSessionAction };
93
+ }
94
+
95
+ function isRunAdmissionAction(action: SessionActionEnvelope): boolean {
96
+ return action.actionType === "chat.prompt" || action.actionType === "tool.invoke";
97
+ }
@@ -9,7 +9,6 @@ export interface MarketStateSnapshot {
9
9
  instruments: Array<NonNullable<ReturnType<MarketStateService["getInstrument"]>>>;
10
10
  watchlist: ReturnType<MarketStateService["listWatchlistItems"]>;
11
11
  portfolio: ReturnType<MarketStateService["listPortfolioLots"]>;
12
- predictions: ReturnType<MarketStateService["listPredictions"]>;
13
12
  alerts: ReturnType<MarketStateService["listAlertRules"]>;
14
13
  alertEvents: ReturnType<MarketStateService["listAlertEvents"]>;
15
14
  alertCheckRuns: ReturnType<MarketStateService["listAlertCheckRuns"]>;
@@ -51,17 +50,6 @@ export interface MarketStateQuoteSnapshot {
51
50
  stale?: boolean;
52
51
  reason?: string;
53
52
  }>;
54
- predictionQuotes: Array<{
55
- predictionId: number;
56
- instrumentId: number;
57
- symbol: string;
58
- status: "ok" | "unavailable";
59
- currentPrice?: number;
60
- changePercent?: number;
61
- fetchedAt?: string;
62
- stale?: boolean;
63
- reason?: string;
64
- }>;
65
53
  portfolioSummary: {
66
54
  baseCurrency: string;
67
55
  totalValue: number;
@@ -86,7 +74,6 @@ export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSna
86
74
  .filter((instrument) => instrument != null),
87
75
  watchlist: service.listWatchlistItems(),
88
76
  portfolio: service.listPortfolioLots(),
89
- predictions: service.listPredictions(),
90
77
  alerts,
91
78
  alertEvents: service.listAlertEvents(),
92
79
  alertCheckRuns: service.listAlertCheckRuns(),
@@ -109,13 +96,8 @@ export async function buildMarketStateQuoteSnapshot(
109
96
  try {
110
97
  const watchlist = service.listWatchlistItems();
111
98
  const portfolio = service.listPortfolioLots();
112
- const predictions = service.listPredictions();
113
99
  const symbols = [
114
- ...new Set([
115
- ...watchlist.map((item) => item.symbol),
116
- ...portfolio.map((lot) => lot.symbol),
117
- ...predictions.map((prediction) => prediction.symbol),
118
- ]),
100
+ ...new Set([...watchlist.map((item) => item.symbol), ...portfolio.map((lot) => lot.symbol)]),
119
101
  ];
120
102
  const quoteMap = new Map<string, Awaited<ReturnType<typeof fetchQuoteSnapshot>>>();
121
103
  for (const symbol of symbols) {
@@ -224,34 +206,10 @@ export async function buildMarketStateQuoteSnapshot(
224
206
  currency: quote.currency,
225
207
  reason: quote.reason ?? "quote unavailable",
226
208
  }));
227
- const predictionQuotes = predictions.map((prediction) => {
228
- const quote = quoteMap.get(prediction.symbol);
229
- if (quote == null || quote.status === "unavailable") {
230
- return {
231
- predictionId: prediction.id,
232
- instrumentId: prediction.instrumentId,
233
- symbol: prediction.symbol,
234
- status: "unavailable" as const,
235
- reason: quote?.reason ?? "quote unavailable",
236
- };
237
- }
238
- return {
239
- predictionId: prediction.id,
240
- instrumentId: prediction.instrumentId,
241
- symbol: prediction.symbol,
242
- status: "ok" as const,
243
- currentPrice: quote.price,
244
- changePercent: quote.changePercent,
245
- fetchedAt: quote.fetchedAt,
246
- stale: quote.stale,
247
- };
248
- });
249
-
250
209
  return {
251
210
  generatedAt,
252
211
  watchlistQuotes,
253
212
  portfolioQuotes: portfolioQuotesWithAllocation,
254
- predictionQuotes,
255
213
  portfolioSummary: {
256
214
  baseCurrency,
257
215
  totalValue,
@@ -25,6 +25,7 @@ import {
25
25
  import { createInitialGuiSessionManager } from "./gui-session-manager.js";
26
26
  import { createHttpRequestHandler, resolveSessionManagerById } from "./http-routes.js";
27
27
  import { createToolInvokeController } from "./invoke-tool.js";
28
+ import { createLocalSessionCoordinator } from "./local-session-coordinator.js";
28
29
  import { buildMarketStateQuoteSnapshot } from "./market-state-api.js";
29
30
  import { createModelSetupController } from "./model-setup.js";
30
31
  import { isTrustedPrivateApiRequest } from "./private-api-access.js";
@@ -33,6 +34,7 @@ import { createSessionActionsController } from "./session-actions.js";
33
34
  import { createGracefulShutdown } from "./shutdown.js";
34
35
  import {
35
36
  acquireWriterLock,
37
+ migrateWriterLockScope,
36
38
  refreshWriterLock,
37
39
  releaseWriterLock,
38
40
  writerLockScopeForSession,
@@ -49,6 +51,8 @@ const automationHeartbeatMs = normalizeAutomationHeartbeatMs(
49
51
  );
50
52
  const allowRemotePrivateApi = process.env.OPENCANDLE_GUI_ALLOW_REMOTE_PRIVATE_API === "1";
51
53
  const privateApiSessionToken = randomBytes(32).toString("base64url");
54
+ const localCoordinatorSecret = randomBytes(32).toString("base64url");
55
+ const localCoordinatorEndpoint = `http://${coordinatorEndpointHost(host)}:${port}`;
52
56
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
53
57
  const webDist = resolve(__dirname, "../web/dist");
54
58
 
@@ -60,8 +64,12 @@ const initialSessionManager = createInitialGuiSessionManager(cwd);
60
64
  let sessionManager = initialSessionManager;
61
65
  const sessionDir = sessionManager.getSessionDir();
62
66
  const initialWriterLockScope = writerLockScopeForSession(sessionManager);
63
- const lockResult = await acquireWriterLock(initialWriterLockScope, "gui");
67
+ const lockResult = await acquireWriterLock(initialWriterLockScope, "gui", {
68
+ coordinatorEndpoint: localCoordinatorEndpoint,
69
+ coordinatorSecret: localCoordinatorSecret,
70
+ });
64
71
  let activeWriterLockScope = initialWriterLockScope;
72
+ let currentWriterLockLost = false;
65
73
  let wsHub: WsHub;
66
74
  let quotePoller: BackgroundQuotePoller;
67
75
  const askUserBridge = createAskUserBridge({
@@ -91,8 +99,28 @@ const runtime = await createAgentSessionRuntime(
91
99
  { cwd, agentDir, sessionManager },
92
100
  );
93
101
  let session = runtime.session;
94
- const heartbeat = setInterval(() => refreshWriterLock(activeWriterLockScope), 5000);
102
+ function syncCurrentWriterLockScope(): void {
103
+ if (currentWriterLockLost) throw new Error("OpenCandle is reconnecting to this session.");
104
+ if (lockResult.role !== "writer") return;
105
+ const nextScope = writerLockScopeForSession(sessionManager);
106
+ if (nextScope === activeWriterLockScope) return;
107
+ if (migrateWriterLockScope(activeWriterLockScope, nextScope)) {
108
+ activeWriterLockScope = nextScope;
109
+ } else {
110
+ currentWriterLockLost = true;
111
+ throw new Error("OpenCandle is reconnecting to this session.");
112
+ }
113
+ }
114
+ const heartbeat = setInterval(() => {
115
+ try {
116
+ syncCurrentWriterLockScope();
117
+ refreshWriterLock(activeWriterLockScope);
118
+ } catch {
119
+ clearInterval(heartbeat);
120
+ }
121
+ }, 5000);
95
122
  const backgroundQuoteRefreshes = new BackgroundQuoteRefreshes();
123
+ const localSessionCoordinator = createLocalSessionCoordinator();
96
124
  const quoteSnapshotStore = new QuoteSnapshotStore(() => buildMarketStateQuoteSnapshot());
97
125
  quotePoller = createBackgroundQuotePoller({
98
126
  getClientCount: () => wsHub.getClientCount(),
@@ -121,6 +149,10 @@ const toolInvokeController = createToolInvokeController({
121
149
  onMarketStateChanged: () => quoteSnapshotStore.invalidate(),
122
150
  askUserHandler: askUserBridge.ask,
123
151
  askUserHandlerForSessionId: (sessionId) => askUserBridge.askForSession(sessionId),
152
+ localCoordinatorEndpoint,
153
+ localCoordinatorSecret,
154
+ localSessionCoordinator,
155
+ syncWriterLockScope: syncCurrentWriterLockScope,
124
156
  resolveSessionManager: (sessionId) =>
125
157
  resolveSessionManagerById(
126
158
  { cwd, sessionDir, getSessionManager: () => sessionManager },
@@ -139,6 +171,7 @@ const sessionActionsController = createSessionActionsController({
139
171
  sendBoot: (client) => wsHub.sendBoot(client),
140
172
  broadcastState: () => wsHub.broadcastState(),
141
173
  broadcastSessions: () => wsHub.broadcastSessions(),
174
+ localSessionCoordinator,
142
175
  });
143
176
  wsHub = createWsHub({
144
177
  role: lockResult.role,
@@ -163,11 +196,12 @@ let unsubscribeSession = wsHub.subscribeToSessionEvents();
163
196
  runtime.setRebindSession(async (nextSession) => {
164
197
  const nextWriterLockScope = writerLockScopeForSession(nextSession.sessionManager);
165
198
  if (lockResult.role === "writer" && nextWriterLockScope !== activeWriterLockScope) {
166
- const nextLockResult = await acquireWriterLock(nextWriterLockScope, "gui");
199
+ const nextLockResult = await acquireWriterLock(nextWriterLockScope, "gui", {
200
+ coordinatorEndpoint: localCoordinatorEndpoint,
201
+ coordinatorSecret: localCoordinatorSecret,
202
+ });
167
203
  if (nextLockResult.role !== "writer") {
168
- throw new Error(
169
- `Session is currently being written by ${nextLockResult.lock.processKind} (pid ${nextLockResult.lock.pid}).`,
170
- );
204
+ throw new Error("OpenCandle is reconnecting to this session.");
171
205
  }
172
206
  releaseWriterLock(activeWriterLockScope);
173
207
  activeWriterLockScope = nextWriterLockScope;
@@ -187,7 +221,10 @@ const httpRequestHandler = createHttpRequestHandler({
187
221
  agentDir,
188
222
  sessionDir,
189
223
  privateApiSessionToken,
224
+ localCoordinatorEndpoint,
225
+ localCoordinatorSecret,
190
226
  allowRemotePrivateApi,
227
+ syncCurrentWriterLockScope,
191
228
  getSession: () => session,
192
229
  getSessionManager: () => sessionManager,
193
230
  createSessionForManager: async (targetSessionManager) =>
@@ -203,7 +240,9 @@ const httpRequestHandler = createHttpRequestHandler({
203
240
  wsHub,
204
241
  modelSetupController,
205
242
  sessionActionsController,
243
+ toolInvokeController,
206
244
  quoteSnapshotStore,
245
+ localSessionCoordinator,
207
246
  });
208
247
 
209
248
  const server = createServer((req, res) => {
@@ -240,5 +279,11 @@ const shutdown = createGracefulShutdown({
240
279
  exit: (code) => process.exit(code),
241
280
  });
242
281
 
282
+ function coordinatorEndpointHost(bindHost: string): string {
283
+ if (bindHost === "0.0.0.0") return "127.0.0.1";
284
+ if (bindHost === "::") return "[::1]";
285
+ return bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost;
286
+ }
287
+
243
288
  process.once("SIGINT", shutdown);
244
289
  process.once("SIGTERM", shutdown);