opencandle 0.11.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 (162) hide show
  1. package/README.md +5 -4
  2. package/dist/analysts/orchestrator.d.ts +2 -0
  3. package/dist/analysts/orchestrator.js +19 -0
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/doctor/report.js +1 -1
  6. package/dist/doctor/report.js.map +1 -1
  7. package/dist/infra/cache.d.ts +6 -0
  8. package/dist/infra/cache.js +16 -6
  9. package/dist/infra/cache.js.map +1 -1
  10. package/dist/infra/freshness.d.ts +21 -0
  11. package/dist/infra/freshness.js +119 -0
  12. package/dist/infra/freshness.js.map +1 -0
  13. package/dist/infra/market-calendar.d.ts +14 -0
  14. package/dist/infra/market-calendar.js +137 -0
  15. package/dist/infra/market-calendar.js.map +1 -0
  16. package/dist/infra/rate-limiter.js +1 -0
  17. package/dist/infra/rate-limiter.js.map +1 -1
  18. package/dist/market-state/summaries.d.ts +8 -0
  19. package/dist/market-state/summaries.js +70 -0
  20. package/dist/market-state/summaries.js.map +1 -0
  21. package/dist/onboarding/providers.d.ts +25 -1
  22. package/dist/onboarding/providers.js +26 -0
  23. package/dist/onboarding/providers.js.map +1 -1
  24. package/dist/pi/opencandle-extension.js +44 -5
  25. package/dist/pi/opencandle-extension.js.map +1 -1
  26. package/dist/providers/alpha-vantage.js +6 -0
  27. package/dist/providers/alpha-vantage.js.map +1 -1
  28. package/dist/providers/coingecko.js +1 -0
  29. package/dist/providers/coingecko.js.map +1 -1
  30. package/dist/providers/polymarket.d.ts +2 -0
  31. package/dist/providers/polymarket.js +163 -0
  32. package/dist/providers/polymarket.js.map +1 -0
  33. package/dist/providers/wrap-provider.js +5 -3
  34. package/dist/providers/wrap-provider.js.map +1 -1
  35. package/dist/providers/yahoo-finance.d.ts +3 -0
  36. package/dist/providers/yahoo-finance.js +206 -10
  37. package/dist/providers/yahoo-finance.js.map +1 -1
  38. package/dist/routing/entity-extractor.js +16 -0
  39. package/dist/routing/entity-extractor.js.map +1 -1
  40. package/dist/routing/route-manifest.js +1 -1
  41. package/dist/routing/route-manifest.js.map +1 -1
  42. package/dist/routing/router-llm-client.js +18 -3
  43. package/dist/routing/router-llm-client.js.map +1 -1
  44. package/dist/routing/router.d.ts +1 -1
  45. package/dist/routing/router.js +274 -5
  46. package/dist/routing/router.js.map +1 -1
  47. package/dist/routing/slot-resolver.d.ts +1 -0
  48. package/dist/routing/slot-resolver.js +1 -1
  49. package/dist/routing/slot-resolver.js.map +1 -1
  50. package/dist/runtime/evidence.d.ts +1 -0
  51. package/dist/runtime/evidence.js.map +1 -1
  52. package/dist/runtime/numeric-claims.d.ts +23 -0
  53. package/dist/runtime/numeric-claims.js +100 -0
  54. package/dist/runtime/numeric-claims.js.map +1 -0
  55. package/dist/runtime/planning-evidence.js +1 -76
  56. package/dist/runtime/planning-evidence.js.map +1 -1
  57. package/dist/runtime/prompt-step.d.ts +10 -1
  58. package/dist/runtime/prompt-step.js +116 -2
  59. package/dist/runtime/prompt-step.js.map +1 -1
  60. package/dist/runtime/session-coordinator.d.ts +11 -1
  61. package/dist/runtime/session-coordinator.js +352 -49
  62. package/dist/runtime/session-coordinator.js.map +1 -1
  63. package/dist/runtime/validation.d.ts +3 -1
  64. package/dist/runtime/workflow-events.d.ts +1 -1
  65. package/dist/runtime/workflow-events.js.map +1 -1
  66. package/dist/runtime/workflow-types.d.ts +3 -0
  67. package/dist/runtime/workflow-types.js.map +1 -1
  68. package/dist/tools/fundamentals/comps.d.ts +1 -0
  69. package/dist/tools/fundamentals/comps.js +20 -9
  70. package/dist/tools/fundamentals/comps.js.map +1 -1
  71. package/dist/tools/fundamentals/dcf.js +183 -112
  72. package/dist/tools/fundamentals/dcf.js.map +1 -1
  73. package/dist/tools/index.d.ts +1 -0
  74. package/dist/tools/index.js +3 -0
  75. package/dist/tools/index.js.map +1 -1
  76. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  77. package/dist/tools/macro/event-probabilities.js +121 -0
  78. package/dist/tools/macro/event-probabilities.js.map +1 -0
  79. package/dist/tools/market/crypto-price.d.ts +4 -1
  80. package/dist/tools/market/crypto-price.js +10 -1
  81. package/dist/tools/market/crypto-price.js.map +1 -1
  82. package/dist/tools/market/screen-stocks.js +14 -3
  83. package/dist/tools/market/screen-stocks.js.map +1 -1
  84. package/dist/tools/market/stock-quote.d.ts +4 -1
  85. package/dist/tools/market/stock-quote.js +9 -4
  86. package/dist/tools/market/stock-quote.js.map +1 -1
  87. package/dist/tools/options/option-chain.d.ts +4 -1
  88. package/dist/tools/options/option-chain.js +12 -1
  89. package/dist/tools/options/option-chain.js.map +1 -1
  90. package/dist/tools/portfolio/risk-analysis.js +1 -1
  91. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  92. package/dist/tools/technical/indicators.js +1 -1
  93. package/dist/tools/technical/indicators.js.map +1 -1
  94. package/dist/types/fundamentals.d.ts +1 -0
  95. package/dist/types/index.d.ts +1 -0
  96. package/dist/types/market.d.ts +2 -0
  97. package/dist/types/options.d.ts +1 -0
  98. package/dist/types/prediction-markets.d.ts +13 -0
  99. package/dist/types/prediction-markets.js +2 -0
  100. package/dist/types/prediction-markets.js.map +1 -0
  101. package/gui/server/chat-event-adapter.ts +89 -5
  102. package/gui/server/http-routes.ts +285 -37
  103. package/gui/server/invoke-tool.ts +14 -13
  104. package/gui/server/live-chat-event-adapter.ts +37 -2
  105. package/gui/server/market-state-api.ts +63 -5
  106. package/gui/server/projector.ts +132 -3
  107. package/gui/server/session-actions.ts +32 -40
  108. package/gui/server/session-entry-wait.ts +22 -3
  109. package/gui/server/ws-hub.ts +16 -4
  110. package/gui/shared/chat-events.ts +10 -1
  111. package/gui/shared/event-reducer.ts +1 -0
  112. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  113. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  114. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  115. package/gui/web/dist/index.html +2 -2
  116. package/package.json +8 -13
  117. package/src/analysts/orchestrator.ts +20 -0
  118. package/src/doctor/report.ts +1 -1
  119. package/src/infra/cache.ts +24 -11
  120. package/src/infra/freshness.ts +165 -0
  121. package/src/infra/market-calendar.ts +193 -0
  122. package/src/infra/rate-limiter.ts +1 -0
  123. package/src/market-state/summaries.ts +75 -0
  124. package/src/onboarding/providers.ts +29 -1
  125. package/src/pi/opencandle-extension.ts +55 -5
  126. package/src/providers/alpha-vantage.ts +6 -0
  127. package/src/providers/coingecko.ts +2 -0
  128. package/src/providers/polymarket.ts +214 -0
  129. package/src/providers/wrap-provider.ts +5 -3
  130. package/src/providers/yahoo-finance.ts +339 -27
  131. package/src/routing/entity-extractor.ts +17 -0
  132. package/src/routing/route-manifest.ts +1 -1
  133. package/src/routing/router-llm-client.ts +31 -19
  134. package/src/routing/router.ts +340 -5
  135. package/src/routing/slot-resolver.ts +1 -1
  136. package/src/runtime/evidence.ts +1 -0
  137. package/src/runtime/numeric-claims.ts +108 -0
  138. package/src/runtime/planning-evidence.ts +6 -91
  139. package/src/runtime/prompt-step.ts +127 -2
  140. package/src/runtime/session-coordinator.ts +462 -57
  141. package/src/runtime/validation.ts +1 -1
  142. package/src/runtime/workflow-events.ts +2 -0
  143. package/src/runtime/workflow-types.ts +3 -0
  144. package/src/tools/fundamentals/comps.ts +25 -14
  145. package/src/tools/fundamentals/dcf.ts +204 -124
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/macro/event-probabilities.ts +141 -0
  148. package/src/tools/market/crypto-price.ts +14 -2
  149. package/src/tools/market/screen-stocks.ts +14 -3
  150. package/src/tools/market/stock-quote.ts +49 -43
  151. package/src/tools/options/option-chain.ts +16 -2
  152. package/src/tools/portfolio/risk-analysis.ts +1 -1
  153. package/src/tools/technical/indicators.ts +1 -1
  154. package/src/types/fundamentals.ts +1 -0
  155. package/src/types/index.ts +1 -0
  156. package/src/types/market.ts +2 -0
  157. package/src/types/options.ts +1 -0
  158. package/src/types/prediction-markets.ts +13 -0
  159. package/gui/server/chat-run-session.ts +0 -16
  160. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +0 -1
  161. package/gui/web/dist/assets/index-BzyqyVnd.css +0 -2
  162. package/gui/web/dist/assets/index-DvMjkOP9.js +0 -65
@@ -1,4 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
+ import { buildFreshnessStamp } from "../../src/infra/freshness.js";
2
3
  import { isZeroFilledQuote, searchYahooInstruments } from "../../src/market-state/resolve.js";
3
4
  import { MarketStateService } from "../../src/market-state/service.js";
4
5
  import { initDefaultDatabase } from "../../src/memory/sqlite.js";
@@ -37,12 +38,12 @@ export interface MarketStateQuoteSnapshot {
37
38
  instrumentId: number;
38
39
  symbol: string;
39
40
  status: "ok" | "unavailable";
40
- currentPrice?: number;
41
+ currentPrice?: number | null;
41
42
  changePercent?: number;
42
- marketValue?: number;
43
+ marketValue?: number | null;
43
44
  totalCost: number;
44
- pnl?: number;
45
- pnlPercent?: number;
45
+ pnl?: number | null;
46
+ pnlPercent?: number | null;
46
47
  allocationPercent?: number;
47
48
  currency: string;
48
49
  includedInTotals: boolean;
@@ -60,6 +61,30 @@ export interface MarketStateQuoteSnapshot {
60
61
  };
61
62
  }
62
63
 
64
+ interface SavedSymbolsMemoOptions {
65
+ ttlMs?: number;
66
+ now?: () => number;
67
+ }
68
+
69
+ export function createSavedSymbolsMemo(
70
+ load: () => string[],
71
+ options: SavedSymbolsMemoOptions = {},
72
+ ): () => string[] {
73
+ const ttlMs = options.ttlMs ?? 30_000;
74
+ const now = options.now ?? Date.now;
75
+ let cachedAt = 0;
76
+ let cached: string[] | null = null;
77
+ return () => {
78
+ const current = now();
79
+ if (cached && current - cachedAt < ttlMs) return cached;
80
+ cached = load();
81
+ cachedAt = current;
82
+ return cached;
83
+ };
84
+ }
85
+
86
+ export const getSavedMarketStateSymbols = createSavedSymbolsMemo(loadSavedMarketStateSymbols);
87
+
63
88
  export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSnapshot {
64
89
  const ownedDb = db ?? initDefaultDatabase();
65
90
  const service = new MarketStateService(ownedDb);
@@ -88,6 +113,30 @@ export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSna
88
113
  }
89
114
  }
90
115
 
116
+ function loadSavedMarketStateSymbols(): string[] {
117
+ const db = initDefaultDatabase();
118
+ const service = new MarketStateService(db);
119
+ try {
120
+ const symbols = [
121
+ ...service.listWatchlistItems().map((item) => item.symbol),
122
+ ...service.listPortfolioLots().map((lot) => lot.symbol),
123
+ ];
124
+ return normalizeSymbols(symbols);
125
+ } finally {
126
+ db.close();
127
+ }
128
+ }
129
+
130
+ function normalizeSymbols(symbols: string[]): string[] {
131
+ const normalized: string[] = [];
132
+ for (const symbol of symbols) {
133
+ const next = symbol.trim().toUpperCase();
134
+ if (!next || normalized.includes(next)) continue;
135
+ normalized.push(next);
136
+ }
137
+ return normalized;
138
+ }
139
+
91
140
  export async function buildMarketStateQuoteSnapshot(
92
141
  db?: Database.Database,
93
142
  ): Promise<MarketStateQuoteSnapshot> {
@@ -262,11 +311,20 @@ async function fetchQuoteSnapshot(symbol: string): Promise<
262
311
  if (isZeroFilledQuote(result.data)) {
263
312
  return { status: "unavailable", reason: "Yahoo returned no valid market data." };
264
313
  }
314
+ const freshness = buildFreshnessStamp({
315
+ asOf: result.data.asOf,
316
+ cached: result.cached,
317
+ stale: result.stale,
318
+ cachedAt: result.timestamp,
319
+ });
320
+ if (freshness.isStaleForSession) {
321
+ return { status: "unavailable", reason: "provider returned stale market data" };
322
+ }
265
323
  return {
266
324
  status: "ok",
267
325
  price: result.data.price,
268
326
  changePercent: result.data.changePercent,
269
- fetchedAt: result.timestamp,
327
+ fetchedAt: freshness.providerDataAt ?? result.timestamp,
270
328
  stale: result.stale,
271
329
  currency: result.data.currency ?? null,
272
330
  };
@@ -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
  }
@@ -29,7 +29,6 @@ interface SessionActionClient {
29
29
  }
30
30
 
31
31
  export interface SessionActionsController {
32
- handlePrompt(prompt: string): Promise<void>;
33
32
  handleAskUserAnswer(id: string, value: unknown, action?: SessionActionMeta): Promise<void>;
34
33
  handleAskUserCancel(id: string, action?: SessionActionMeta): Promise<void>;
35
34
  handleNewSession(): Promise<void>;
@@ -80,32 +79,6 @@ export function createSessionActionsController({
80
79
  if (role !== "writer") throw new Error("Read-only follower mode");
81
80
  }
82
81
 
83
- async function handlePrompt(prompt: string): Promise<void> {
84
- ensureWriter();
85
-
86
- const modelSetup = getModelSetupState();
87
- const sessionManager = getSessionManager();
88
- const trimmedPrompt = prompt.trim();
89
- if (!trimmedPrompt.startsWith("/") && modelSetup.requirement !== "ready") {
90
- sessionManager.appendMessage({ role: "user", content: prompt, timestamp: now() });
91
- broadcastState();
92
- const message =
93
- modelSetup.requirement === "select_model"
94
- ? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
95
- : "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
96
- sessionManager.appendCustomMessageEntry("opencandle-model-setup", message, true, {
97
- source: "gui",
98
- requirement: modelSetup.requirement,
99
- });
100
- broadcastState();
101
- return;
102
- }
103
-
104
- const beforeIds = new Set(sessionManager.getEntries().map((entry) => entry.id));
105
- await promptAndSettle(getSession(), prompt, beforeIds);
106
- broadcastState();
107
- }
108
-
109
82
  async function handleAskUserAnswer(
110
83
  id: string,
111
84
  value: unknown,
@@ -172,7 +145,6 @@ export function createSessionActionsController({
172
145
  }
173
146
 
174
147
  return {
175
- handlePrompt,
176
148
  handleAskUserAnswer,
177
149
  handleAskUserCancel,
178
150
  handleNewSession,
@@ -191,7 +163,8 @@ export function createSessionActionsController({
191
163
  await handler();
192
164
  return;
193
165
  }
194
- const sessionId = action.sessionId?.trim() || getSessionManager().getSessionId();
166
+ const sessionId = action.sessionId?.trim();
167
+ if (!sessionId) throw new Error("sessionId is required");
195
168
  const result = await localSessionCoordinator.runSessionAction(
196
169
  {
197
170
  sessionId,
@@ -230,7 +203,7 @@ export function createSessionActionsController({
230
203
  "x-opencandle-coordinator-secret": lock.coordinatorSecret,
231
204
  },
232
205
  body: JSON.stringify({
233
- sessionId: action?.sessionId || sessionManager.getSessionId(),
206
+ sessionId: action?.sessionId,
234
207
  actionId: action?.actionId || "",
235
208
  actionType,
236
209
  payload,
@@ -258,7 +231,8 @@ export function createSessionActionsController({
258
231
  if (!targetSessionId || targetSessionId === current.getSessionId()) return current;
259
232
  const sessions = await SessionManager.list(cwd, sessionDir);
260
233
  const match = sessions.find((candidate) => candidate.id === targetSessionId);
261
- return match ? SessionManager.open(match.path, sessionDir, cwd) : current;
234
+ if (!match) throw new Error("Unknown saved session");
235
+ return SessionManager.open(match.path, sessionDir, cwd);
262
236
  }
263
237
  }
264
238
 
@@ -267,12 +241,10 @@ export async function promptAndSettle(
267
241
  prompt: string,
268
242
  beforeIds: Set<string>,
269
243
  observation?: PromptObservation,
244
+ options?: { images?: Array<{ type: "image"; data: string; mimeType: string }> },
270
245
  ): Promise<void> {
271
- await runSession.prompt(prompt);
272
- await waitForSessionTurnSettlement(() => ({
273
- isStreaming: runSession.isStreaming,
274
- pendingMessageCount: runSession.pendingMessageCount,
275
- }));
246
+ await runSession.prompt(prompt, options);
247
+ await settleWithEventProgress(runSession);
276
248
  await waitForNewEntryId(
277
249
  () => runSession.sessionManager.getEntries().map((entry) => entry.id),
278
250
  beforeIds,
@@ -281,6 +253,27 @@ export async function promptAndSettle(
281
253
  await replayObservedWorkflowPromptIfNeeded(runSession, prompt, observation);
282
254
  }
283
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
+
284
277
  export async function replayObservedWorkflowPromptIfNeeded(
285
278
  runSession: AgentSession,
286
279
  originalPrompt: string,
@@ -290,14 +283,13 @@ export async function replayObservedWorkflowPromptIfNeeded(
290
283
  const replayPrompt = selectReplayPrompt(observation, originalPrompt);
291
284
  if (!replayPrompt) return;
292
285
 
286
+ // V1 attachment replay is text-only: the recorded expanded prompt is
287
+ // re-sent, but image bytes are not replayed.
293
288
  await runSession.prompt(replayPrompt, {
294
289
  expandPromptTemplates: false,
295
290
  source: "extension",
296
291
  });
297
- await waitForSessionTurnSettlement(() => ({
298
- isStreaming: runSession.isStreaming,
299
- pendingMessageCount: runSession.pendingMessageCount,
300
- }));
292
+ await settleWithEventProgress(runSession);
301
293
  await waitForResolvedToolCalls(() => runSession.sessionManager.getEntries());
302
294
  }
303
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[] {
@@ -16,6 +16,7 @@ import type { ChatEvent } from "../shared/chat-events.js";
16
16
  import type { BackgroundQuoteRefreshes } from "./background-quotes.js";
17
17
  import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
18
18
  import type { ToolInvokeController } from "./invoke-tool.js";
19
+ import { getSavedMarketStateSymbols } from "./market-state-api.js";
19
20
  import type { ModelSetupController } from "./model-setup.js";
20
21
  import { projectDashboard } from "./projector.js";
21
22
  import type { SessionActionsController } from "./session-actions.js";
@@ -109,8 +110,11 @@ export function createWsHub({
109
110
  try {
110
111
  switch (data.type) {
111
112
  case "chat.prompt":
112
- await sessionActionsController.handlePrompt(String(data.prompt ?? ""));
113
- break;
113
+ // Legacy implicit-active-session mutation; parity with the HTTP
114
+ // route's 410. Chat runs go through the session-addressed API.
115
+ throw new Error(
116
+ "Legacy active-session chat prompts are no longer supported. Use the session-addressed chat-run API.",
117
+ );
114
118
  case "ask_user.answer":
115
119
  await sessionActionsController.handleAskUserAnswer(String(data.id ?? ""), data.answer, {
116
120
  actionId: String(data.actionId ?? ""),
@@ -281,7 +285,11 @@ export function createWsHub({
281
285
  const entries = sessionManager.getEntries();
282
286
  return {
283
287
  sessionId,
284
- state: projectDashboard(backgroundQuoteRefreshes.withEntries(entries), sessionId),
288
+ state: projectDashboard(
289
+ backgroundQuoteRefreshes.withEntries(entries),
290
+ sessionId,
291
+ getSavedMarketStateSymbols(),
292
+ ),
285
293
  entries,
286
294
  events: currentChatEvents(entries),
287
295
  // Ownership can change mid-run (e.g. a TUI takes over the session), so
@@ -295,7 +303,11 @@ export function createWsHub({
295
303
  const entries = sessionManager.getEntries();
296
304
  return {
297
305
  sessionId,
298
- state: projectDashboard(backgroundQuoteRefreshes.withEntries(entries), sessionId),
306
+ state: projectDashboard(
307
+ backgroundQuoteRefreshes.withEntries(entries),
308
+ sessionId,
309
+ getSavedMarketStateSymbols(),
310
+ ),
299
311
  entries,
300
312
  events: sessionEntriesToChatEvents(entries, {
301
313
  sessionId,
@@ -12,12 +12,19 @@ export interface MessageToolContent {
12
12
 
13
13
  export interface MessageImageContent {
14
14
  type: "image";
15
- url: string;
15
+ url?: string;
16
+ data?: string;
17
+ mimeType?: string;
16
18
  alt?: string;
17
19
  }
18
20
 
19
21
  export type MessageContent = MessageTextContent | MessageToolContent | MessageImageContent;
20
22
 
23
+ export interface MessageAttachmentChip {
24
+ kind: string;
25
+ label: string;
26
+ }
27
+
21
28
  export interface ToolOutput {
22
29
  content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
23
30
  details?: unknown;
@@ -63,6 +70,7 @@ export type ChatEvent =
63
70
  type: "message.completed";
64
71
  messageId: string;
65
72
  content: MessageContent[];
73
+ attachments?: MessageAttachmentChip[];
66
74
  runId?: string;
67
75
  } & ChatEventBase)
68
76
  | ({
@@ -98,6 +106,7 @@ export interface RenderMessage {
98
106
  status: "streaming" | "completed";
99
107
  content: MessageContent[];
100
108
  text: string;
109
+ attachments?: MessageAttachmentChip[];
101
110
  customType?: string;
102
111
  }
103
112
 
@@ -66,6 +66,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
66
66
  message.status = "completed";
67
67
  message.content = event.content;
68
68
  message.text = contentText(event.content);
69
+ message.attachments = event.attachments;
69
70
  break;
70
71
  }
71
72