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
@@ -2,6 +2,7 @@ import type { Message, ToolResultMessage } from "@earendil-works/pi-ai";
2
2
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  export interface DashboardState {
5
+ knownSymbols: string[];
5
6
  watchlist: Array<{
6
7
  symbol: string;
7
8
  quote: Record<string, unknown> | null;
@@ -26,6 +27,16 @@ export interface DashboardState {
26
27
  softGaps: Array<{ provider: string; lastSeen: string }>;
27
28
  hardSkips: Array<{ provider: string; lastSeen: string }>;
28
29
  };
30
+ lastTurn?: {
31
+ routeKind: string;
32
+ workflow?: string;
33
+ symbols: string[];
34
+ slotSources: Record<string, number>;
35
+ priorTurnCount: number;
36
+ savedStateIncluded?: boolean;
37
+ attachmentCount?: number;
38
+ validation?: { passed: boolean; mismatchCount: number };
39
+ };
29
40
  }
30
41
 
31
42
  const DIRECT_TOOL_GAP_PROVIDERS: Record<string, string> = {
@@ -41,6 +52,7 @@ const DIRECT_TOOL_GAP_PROVIDERS: Record<string, string> = {
41
52
 
42
53
  export function createEmptyDashboardState(): DashboardState {
43
54
  return {
55
+ knownSymbols: [],
44
56
  watchlist: [],
45
57
  activeAnalyses: [],
46
58
  recentResearch: [],
@@ -48,12 +60,26 @@ export function createEmptyDashboardState(): DashboardState {
48
60
  };
49
61
  }
50
62
 
51
- export function projectDashboard(entries: SessionEntry[], sessionId = "local"): DashboardState {
63
+ export function projectDashboard(
64
+ entries: SessionEntry[],
65
+ sessionId = "local",
66
+ savedSymbols: string[] = [],
67
+ ): DashboardState {
52
68
  const state = createEmptyDashboardState();
69
+ let pendingAttachmentCount: number | undefined;
70
+ let pendingAttachmentUserSeen = false;
53
71
 
54
72
  for (const entry of entries) {
55
73
  if (entry.type === "message") {
56
74
  projectMessage(state, entry.message as Message, entry.timestamp, sessionId);
75
+ const message = entry.message as Message;
76
+ if (pendingAttachmentCount !== undefined) {
77
+ if (message.role === "user") pendingAttachmentUserSeen = true;
78
+ else if (pendingAttachmentUserSeen) {
79
+ pendingAttachmentCount = undefined;
80
+ pendingAttachmentUserSeen = false;
81
+ }
82
+ }
57
83
  continue;
58
84
  }
59
85
 
@@ -73,6 +99,54 @@ export function projectDashboard(entries: SessionEntry[], sessionId = "local"):
73
99
  continue;
74
100
  }
75
101
 
102
+ if (entry.type === "custom" && entry.customType === "opencandle-user-input") {
103
+ const attachments = asArray(asRecord(entry.data).attachments);
104
+ pendingAttachmentCount = attachments.length > 0 ? attachments.length : undefined;
105
+ pendingAttachmentUserSeen = false;
106
+ if (state.lastTurn) {
107
+ if (pendingAttachmentCount !== undefined)
108
+ state.lastTurn.attachmentCount = pendingAttachmentCount;
109
+ else delete state.lastTurn.attachmentCount;
110
+ }
111
+ continue;
112
+ }
113
+
114
+ if (entry.type === "custom" && entry.customType === "opencandle-route-context") {
115
+ state.lastTurn = projectRouteContext(entry.data, pendingAttachmentCount);
116
+ addKnownSymbols(state, asArray(asRecord(asRecord(entry.data).entities).symbols));
117
+ pendingAttachmentCount = undefined;
118
+ pendingAttachmentUserSeen = false;
119
+ continue;
120
+ }
121
+
122
+ if (pendingAttachmentUserSeen) {
123
+ pendingAttachmentCount = undefined;
124
+ pendingAttachmentUserSeen = false;
125
+ }
126
+
127
+ if (entry.type === "custom" && entry.customType === "opencandle-validation") {
128
+ if (state.lastTurn) {
129
+ const data = asRecord(entry.data);
130
+ state.lastTurn.validation = {
131
+ passed: Boolean(data.passed),
132
+ mismatchCount: asArray(data.mismatches).length,
133
+ };
134
+ }
135
+ continue;
136
+ }
137
+
138
+ if (entry.type === "custom" && entry.customType === "opencandle-analyst-step") {
139
+ // debate_* stages share this entry type but are not analysts.
140
+ const stage = stringValue(asRecord(entry.data).stage) ?? "";
141
+ const active = state.activeAnalyses[state.activeAnalyses.length - 1];
142
+ if (active && stage.startsWith("analyst_")) {
143
+ const nextDone = active.analystsDone + 1;
144
+ active.analystsDone =
145
+ active.analystsTotal > 0 ? Math.min(nextDone, active.analystsTotal) : nextDone;
146
+ }
147
+ continue;
148
+ }
149
+
76
150
  if (entry.type === "custom" && entry.customType === "opencandle-turn-gap") {
77
151
  // The accumulator writes a single combined annotation string with one
78
152
  // [OPENCANDLE_SKIPPED ... provider=X ...] tag per fallback provider.
@@ -105,9 +179,40 @@ export function projectDashboard(entries: SessionEntry[], sessionId = "local"):
105
179
  }
106
180
  }
107
181
 
182
+ addKnownSymbols(state, savedSymbols);
108
183
  return state;
109
184
  }
110
185
 
186
+ function projectRouteContext(
187
+ data: unknown,
188
+ attachmentCount: number | undefined,
189
+ ): DashboardState["lastTurn"] {
190
+ const record = asRecord(data);
191
+ const entities = asRecord(record.entities);
192
+ const routeKind = stringValue(record.routeKind) ?? "unknown";
193
+ const workflow = stringValue(record.workflow);
194
+ const slots = asRecord(record.slots);
195
+ const slotSources: Record<string, number> = {};
196
+ for (const slot of Object.values(slots)) {
197
+ const source = stringValue(asRecord(slot).source);
198
+ if (!source) continue;
199
+ slotSources[source] = (slotSources[source] ?? 0) + 1;
200
+ }
201
+ const symbols = stringArray(entities.symbols);
202
+ const priorTurnCount = asArray(record.priorTurns).length;
203
+ return {
204
+ routeKind,
205
+ ...(workflow ? { workflow } : {}),
206
+ symbols,
207
+ slotSources,
208
+ priorTurnCount,
209
+ ...(typeof record.savedStateIncluded === "boolean"
210
+ ? { savedStateIncluded: record.savedStateIncluded }
211
+ : {}),
212
+ ...(attachmentCount !== undefined ? { attachmentCount } : {}),
213
+ };
214
+ }
215
+
111
216
  function projectMessage(
112
217
  state: DashboardState,
113
218
  message: Message,
@@ -185,10 +290,13 @@ function projectQuote(
185
290
  ): void {
186
291
  const symbol = symbolHint ?? inferSymbolFromContent(content);
187
292
  if (!symbol) return;
293
+ const normalizedSymbol = normalizeSymbol(symbol);
294
+ if (!normalizedSymbol) return;
295
+ addKnownSymbols(state, [normalizedSymbol]);
188
296
 
189
- const existing = state.watchlist.find((row) => row.symbol === symbol);
297
+ const existing = state.watchlist.find((row) => row.symbol === normalizedSymbol);
190
298
  const row = {
191
- symbol,
299
+ symbol: normalizedSymbol,
192
300
  quote: Object.keys(details).length > 0 ? details : null,
193
301
  pinned: existing?.pinned ?? false,
194
302
  lastSeen: timestamp,
@@ -240,6 +348,21 @@ function inferSymbolFromContent(content: ToolResultMessage["content"]): string |
240
348
  return match?.[1];
241
349
  }
242
350
 
351
+ function addKnownSymbols(state: DashboardState, values: unknown[]): void {
352
+ for (const value of values) {
353
+ const symbol = normalizeSymbol(value);
354
+ if (!symbol || state.knownSymbols.includes(symbol)) continue;
355
+ if (state.knownSymbols.length >= 100) return;
356
+ state.knownSymbols.push(symbol);
357
+ }
358
+ }
359
+
360
+ function normalizeSymbol(value: unknown): string | null {
361
+ if (typeof value !== "string") return null;
362
+ const normalized = value.trim().toUpperCase();
363
+ return normalized ? normalized : null;
364
+ }
365
+
243
366
  function asRecord(value: unknown): Record<string, unknown> {
244
367
  return typeof value === "object" && value !== null && !Array.isArray(value)
245
368
  ? (value as Record<string, unknown>)
@@ -258,6 +381,12 @@ function firstString(value: unknown): string | undefined {
258
381
  return Array.isArray(value) ? value.find((item) => typeof item === "string") : undefined;
259
382
  }
260
383
 
384
+ function stringArray(value: unknown): string[] {
385
+ return Array.isArray(value)
386
+ ? value.filter((item): item is string => typeof item === "string")
387
+ : [];
388
+ }
389
+
261
390
  function numberValue(value: unknown): number | undefined {
262
391
  return typeof value === "number" ? value : undefined;
263
392
  }
@@ -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);
@@ -1,5 +1,10 @@
1
1
  import { unlink } from "node:fs/promises";
2
2
  import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import type {
4
+ LocalSessionCoordinator,
5
+ SessionActionEnvelope,
6
+ SessionActionSource,
7
+ } from "./local-session-coordinator.js";
3
8
  import type { ModelSetupState } from "./model-setup.js";
4
9
  import { type PromptObservation, selectReplayPrompt } from "./prompt-observation.js";
5
10
  import {
@@ -7,6 +12,7 @@ import {
7
12
  waitForResolvedToolCalls,
8
13
  waitForSessionTurnSettlement,
9
14
  } from "./session-entry-wait.js";
15
+ import { readWriterLock, writerLockScopeForSession } from "./writer-lock.js";
10
16
 
11
17
  interface AskUserBridge {
12
18
  answer(id: string, answer: string): boolean;
@@ -23,9 +29,8 @@ interface SessionActionClient {
23
29
  }
24
30
 
25
31
  export interface SessionActionsController {
26
- handlePrompt(prompt: string): Promise<void>;
27
- handleAskUserAnswer(id: string, value: unknown): Promise<void>;
28
- handleAskUserCancel(id: string): Promise<void>;
32
+ handleAskUserAnswer(id: string, value: unknown, action?: SessionActionMeta): Promise<void>;
33
+ handleAskUserCancel(id: string, action?: SessionActionMeta): Promise<void>;
29
34
  handleNewSession(): Promise<void>;
30
35
  handleOpenSession(path: string): Promise<void>;
31
36
  handleRenameSession(path: string, name: string): Promise<void>;
@@ -44,9 +49,17 @@ export interface SessionActionsControllerOptions {
44
49
  sendBoot: (client: SessionActionClient) => void;
45
50
  broadcastState: () => void;
46
51
  broadcastSessions: () => void;
52
+ localSessionCoordinator?: LocalSessionCoordinator;
47
53
  now?: () => number;
48
54
  }
49
55
 
56
+ export interface SessionActionMeta {
57
+ sessionId?: string;
58
+ actionId?: string;
59
+ source?: SessionActionSource;
60
+ allowProxy?: boolean;
61
+ }
62
+
50
63
  export function createSessionActionsController({
51
64
  role,
52
65
  cwd,
@@ -59,48 +72,37 @@ export function createSessionActionsController({
59
72
  sendBoot,
60
73
  broadcastState,
61
74
  broadcastSessions,
75
+ localSessionCoordinator,
62
76
  now = Date.now,
63
77
  }: SessionActionsControllerOptions): SessionActionsController {
64
78
  function ensureWriter(): void {
65
79
  if (role !== "writer") throw new Error("Read-only follower mode");
66
80
  }
67
81
 
68
- async function handlePrompt(prompt: string): Promise<void> {
69
- ensureWriter();
70
-
71
- const modelSetup = getModelSetupState();
72
- const sessionManager = getSessionManager();
73
- const trimmedPrompt = prompt.trim();
74
- if (!trimmedPrompt.startsWith("/") && modelSetup.requirement !== "ready") {
75
- sessionManager.appendMessage({ role: "user", content: prompt, timestamp: now() });
76
- broadcastState();
77
- const message =
78
- modelSetup.requirement === "select_model"
79
- ? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
80
- : "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
81
- sessionManager.appendCustomMessageEntry("opencandle-model-setup", message, true, {
82
- source: "gui",
83
- requirement: modelSetup.requirement,
84
- });
85
- broadcastState();
86
- return;
87
- }
88
-
89
- const beforeIds = new Set(sessionManager.getEntries().map((entry) => entry.id));
90
- await promptAndSettle(getSession(), prompt, beforeIds);
91
- broadcastState();
92
- }
93
-
94
- async function handleAskUserAnswer(id: string, value: unknown): Promise<void> {
95
- ensureWriter();
82
+ async function handleAskUserAnswer(
83
+ id: string,
84
+ value: unknown,
85
+ action?: SessionActionMeta,
86
+ ): Promise<void> {
96
87
  const answer = String(value ?? "").trim();
97
88
  if (!answer) throw new Error("Answer cannot be empty");
98
- if (!askUserBridge.answer(id, answer)) throw new Error("Unknown or resolved question");
89
+ if (shouldProxyAskUserAction(action)) {
90
+ if (await proxyAskUserAction("ask_user.answer", { id, answer }, action)) return;
91
+ throw new Error("OpenCandle is reconnecting to this session.");
92
+ }
93
+ await runCoordinatedSessionAction("ask_user.answer", { id, answer }, action, async () => {
94
+ if (!askUserBridge.answer(id, answer)) throw new Error("Unknown or resolved question");
95
+ });
99
96
  }
100
97
 
101
- async function handleAskUserCancel(id: string): Promise<void> {
102
- ensureWriter();
103
- if (!askUserBridge.cancel(id)) throw new Error("Unknown or resolved question");
98
+ async function handleAskUserCancel(id: string, action?: SessionActionMeta): Promise<void> {
99
+ if (shouldProxyAskUserAction(action)) {
100
+ if (await proxyAskUserAction("ask_user.cancel", { id }, action)) return;
101
+ throw new Error("OpenCandle is reconnecting to this session.");
102
+ }
103
+ await runCoordinatedSessionAction("ask_user.cancel", { id }, action, async () => {
104
+ if (!askUserBridge.cancel(id)) throw new Error("Unknown or resolved question");
105
+ });
104
106
  }
105
107
 
106
108
  async function handleNewSession(): Promise<void> {
@@ -143,7 +145,6 @@ export function createSessionActionsController({
143
145
  }
144
146
 
145
147
  return {
146
- handlePrompt,
147
148
  handleAskUserAnswer,
148
149
  handleAskUserCancel,
149
150
  handleNewSession,
@@ -151,6 +152,88 @@ export function createSessionActionsController({
151
152
  handleRenameSession,
152
153
  handleDeleteSession,
153
154
  };
155
+
156
+ async function runCoordinatedSessionAction(
157
+ actionType: SessionActionEnvelope["actionType"],
158
+ payload: Record<string, unknown>,
159
+ action: SessionActionMeta | undefined,
160
+ handler: () => Promise<void> | void,
161
+ ): Promise<void> {
162
+ if (!localSessionCoordinator || !action?.actionId) {
163
+ await handler();
164
+ return;
165
+ }
166
+ const sessionId = action.sessionId?.trim();
167
+ if (!sessionId) throw new Error("sessionId is required");
168
+ const result = await localSessionCoordinator.runSessionAction(
169
+ {
170
+ sessionId,
171
+ actionId: action.actionId,
172
+ actionType,
173
+ payload,
174
+ source: action.source ?? "gui",
175
+ },
176
+ async () => {
177
+ await handler();
178
+ return { accepted: true };
179
+ },
180
+ );
181
+ if (!result.ok) throw new Error(result.message);
182
+ }
183
+
184
+ async function proxyAskUserAction(
185
+ actionType: "ask_user.answer" | "ask_user.cancel",
186
+ payload: Record<string, unknown>,
187
+ action: SessionActionMeta | undefined,
188
+ ): Promise<boolean> {
189
+ if (action?.allowProxy === false) return false;
190
+ const sessionManager = await resolveActionSessionManager(action);
191
+ const lock = readWriterLock(writerLockScopeForSession(sessionManager));
192
+ if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret || lock.pid === process.pid) {
193
+ return false;
194
+ }
195
+ if (lock.processKind === "tui") return false;
196
+ const endpoint = new URL("/api/local-coordinator/ask-user", lock.coordinatorEndpoint);
197
+ let response: Response;
198
+ try {
199
+ response = await fetch(endpoint, {
200
+ method: "POST",
201
+ headers: {
202
+ "content-type": "application/json",
203
+ "x-opencandle-coordinator-secret": lock.coordinatorSecret,
204
+ },
205
+ body: JSON.stringify({
206
+ sessionId: action?.sessionId,
207
+ actionId: action?.actionId || "",
208
+ actionType,
209
+ payload,
210
+ }),
211
+ });
212
+ } catch {
213
+ return false;
214
+ }
215
+ if (response.ok) return true;
216
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
217
+ throw new Error(body.error || "OpenCandle is reconnecting to this session.");
218
+ }
219
+
220
+ function shouldProxyAskUserAction(action: SessionActionMeta | undefined): boolean {
221
+ if (role !== "writer") return true;
222
+ const targetSessionId = action?.sessionId?.trim();
223
+ return Boolean(targetSessionId && targetSessionId !== getSessionManager().getSessionId());
224
+ }
225
+
226
+ async function resolveActionSessionManager(
227
+ action: SessionActionMeta | undefined,
228
+ ): Promise<SessionManager> {
229
+ const current = getSessionManager();
230
+ const targetSessionId = action?.sessionId?.trim();
231
+ if (!targetSessionId || targetSessionId === current.getSessionId()) return current;
232
+ const sessions = await SessionManager.list(cwd, sessionDir);
233
+ const match = sessions.find((candidate) => candidate.id === targetSessionId);
234
+ if (!match) throw new Error("Unknown saved session");
235
+ return SessionManager.open(match.path, sessionDir, cwd);
236
+ }
154
237
  }
155
238
 
156
239
  export async function promptAndSettle(
@@ -158,12 +241,10 @@ export async function promptAndSettle(
158
241
  prompt: string,
159
242
  beforeIds: Set<string>,
160
243
  observation?: PromptObservation,
244
+ options?: { images?: Array<{ type: "image"; data: string; mimeType: string }> },
161
245
  ): Promise<void> {
162
- await runSession.prompt(prompt);
163
- await waitForSessionTurnSettlement(() => ({
164
- isStreaming: runSession.isStreaming,
165
- pendingMessageCount: runSession.pendingMessageCount,
166
- }));
246
+ await runSession.prompt(prompt, options);
247
+ await settleWithEventProgress(runSession);
167
248
  await waitForNewEntryId(
168
249
  () => runSession.sessionManager.getEntries().map((entry) => entry.id),
169
250
  beforeIds,
@@ -172,6 +253,27 @@ export async function promptAndSettle(
172
253
  await replayObservedWorkflowPromptIfNeeded(runSession, prompt, observation);
173
254
  }
174
255
 
256
+ /**
257
+ * Settle wait fed by a session-event counter: a single long model generation
258
+ * keeps isStreaming/pendingMessageCount frozen for its whole duration, and
259
+ * without an activity signal the stall detector killed healthy long turns.
260
+ */
261
+ async function settleWithEventProgress(runSession: AgentSession): Promise<void> {
262
+ let progressToken = 0;
263
+ const unsubscribe = runSession.subscribe(() => {
264
+ progressToken += 1;
265
+ });
266
+ try {
267
+ await waitForSessionTurnSettlement(() => ({
268
+ isStreaming: runSession.isStreaming,
269
+ pendingMessageCount: runSession.pendingMessageCount,
270
+ progressToken,
271
+ }));
272
+ } finally {
273
+ unsubscribe();
274
+ }
275
+ }
276
+
175
277
  export async function replayObservedWorkflowPromptIfNeeded(
176
278
  runSession: AgentSession,
177
279
  originalPrompt: string,
@@ -181,14 +283,13 @@ export async function replayObservedWorkflowPromptIfNeeded(
181
283
  const replayPrompt = selectReplayPrompt(observation, originalPrompt);
182
284
  if (!replayPrompt) return;
183
285
 
286
+ // V1 attachment replay is text-only: the recorded expanded prompt is
287
+ // re-sent, but image bytes are not replayed.
184
288
  await runSession.prompt(replayPrompt, {
185
289
  expandPromptTemplates: false,
186
290
  source: "extension",
187
291
  });
188
- await waitForSessionTurnSettlement(() => ({
189
- isStreaming: runSession.isStreaming,
190
- pendingMessageCount: runSession.pendingMessageCount,
191
- }));
292
+ await settleWithEventProgress(runSession);
192
293
  await waitForResolvedToolCalls(() => runSession.sessionManager.getEntries());
193
294
  }
194
295
 
@@ -12,6 +12,12 @@ export interface WaitForSessionTurnSettlementOptions extends WaitForEntryCountOp
12
12
  export interface SessionRunStatus {
13
13
  isStreaming: boolean;
14
14
  pendingMessageCount: number;
15
+ /**
16
+ * Monotonic activity signal (e.g. a session-event counter). A single long
17
+ * model generation keeps isStreaming/pendingMessageCount frozen for its
18
+ * whole duration; this token is what distinguishes it from a hung run.
19
+ */
20
+ progressToken?: number;
15
21
  }
16
22
 
17
23
  export interface UnresolvedToolCall {
@@ -60,15 +66,29 @@ export async function waitForSessionTurnSettlement(
60
66
  const timeoutMs = options.timeoutMs ?? 120_000;
61
67
  const intervalMs = options.intervalMs ?? 25;
62
68
  const idleGraceMs = options.idleGraceMs ?? 250;
63
- const deadline = Date.now() + timeoutMs;
69
+ // timeoutMs bounds STALL, not total runtime: a live workflow run (e.g.
70
+ // /analyze) stays active for minutes while its status keeps changing, and
71
+ // capping total runtime failed those runs mid-workflow with
72
+ // "Timed out waiting for the session turn to settle". A hung session stops
73
+ // changing status entirely, which is what the deadline must catch.
74
+ let lastProgressAt = Date.now();
75
+ let lastSignature: string | undefined;
64
76
  let idleSince: number | undefined;
65
77
 
66
- while (Date.now() < deadline) {
78
+ while (true) {
67
79
  const status = getStatus();
80
+ const signature = `${status.isStreaming}:${status.pendingMessageCount}:${status.progressToken ?? 0}`;
81
+ if (signature !== lastSignature) {
82
+ lastSignature = signature;
83
+ lastProgressAt = Date.now();
84
+ }
68
85
  const active = status.isStreaming || status.pendingMessageCount > 0;
69
86
 
70
87
  if (active) {
71
88
  idleSince = undefined;
89
+ if (Date.now() - lastProgressAt >= timeoutMs) {
90
+ throw new Error("Timed out waiting for the session turn to settle");
91
+ }
72
92
  await delay(intervalMs);
73
93
  continue;
74
94
  }
@@ -80,7 +100,6 @@ export async function waitForSessionTurnSettlement(
80
100
 
81
101
  await delay(intervalMs);
82
102
  }
83
- throw new Error("Timed out waiting for the session turn to settle");
84
103
  }
85
104
 
86
105
  export function findUnresolvedToolCalls(entries: SessionEntry[]): UnresolvedToolCall[] {
@@ -75,12 +75,14 @@ function serializeProvider(provider: ProviderDescriptor, onboardingState: Onboar
75
75
  };
76
76
 
77
77
  if (isApiKeyProvider(provider)) {
78
+ // Never serialize the credential value: this payload reaches the browser DOM.
78
79
  const credential = getCredential(provider.id);
79
80
  const source = credential.source;
80
81
  return {
81
82
  ...common,
82
83
  source,
83
- apiKey: credential.value,
84
+ configured: source !== "absent",
85
+ maskedKeyHint: credential.value ? `…${credential.value.slice(-4)}` : undefined,
84
86
  status: source,
85
87
  signupUrl: provider.signupUrl,
86
88
  freeTier: provider.freeTier,