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.
- package/README.md +12 -9
- package/dist/analysts/orchestrator.d.ts +3 -4
- package/dist/analysts/orchestrator.js +34 -44
- package/dist/analysts/orchestrator.js.map +1 -1
- package/dist/cli-main.js +242 -9
- package/dist/cli-main.js.map +1 -1
- package/dist/config.d.ts +4 -9
- package/dist/config.js +7 -10
- package/dist/config.js.map +1 -1
- package/dist/doctor/report.js +1 -1
- package/dist/doctor/report.js.map +1 -1
- package/dist/infra/cache.d.ts +6 -0
- package/dist/infra/cache.js +16 -6
- package/dist/infra/cache.js.map +1 -1
- package/dist/infra/freshness.d.ts +21 -0
- package/dist/infra/freshness.js +119 -0
- package/dist/infra/freshness.js.map +1 -0
- package/dist/infra/market-calendar.d.ts +14 -0
- package/dist/infra/market-calendar.js +137 -0
- package/dist/infra/market-calendar.js.map +1 -0
- package/dist/infra/rate-limiter.js +1 -0
- package/dist/infra/rate-limiter.js.map +1 -1
- package/dist/market-state/daily-report.d.ts +1 -1
- package/dist/market-state/daily-report.js +5 -16
- package/dist/market-state/daily-report.js.map +1 -1
- package/dist/market-state/service.d.ts +0 -35
- package/dist/market-state/service.js +0 -63
- package/dist/market-state/service.js.map +1 -1
- package/dist/market-state/summaries.d.ts +8 -0
- package/dist/market-state/summaries.js +70 -0
- package/dist/market-state/summaries.js.map +1 -0
- package/dist/memory/sqlite.js +20 -19
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/onboarding/providers.d.ts +25 -1
- package/dist/onboarding/providers.js +26 -0
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/pi/opencandle-extension.js +57 -226
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-action-dedupe.d.ts +6 -0
- package/dist/pi/session-action-dedupe.js +126 -0
- package/dist/pi/session-action-dedupe.js.map +1 -0
- package/dist/pi/session-writer-lock.d.ts +26 -2
- package/dist/pi/session-writer-lock.js +230 -18
- package/dist/pi/session-writer-lock.js.map +1 -1
- package/dist/pi/setup.js +5 -5
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tui-session-coordinator.d.ts +15 -0
- package/dist/pi/tui-session-coordinator.js +283 -0
- package/dist/pi/tui-session-coordinator.js.map +1 -0
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/providers/alpha-vantage.js +6 -0
- package/dist/providers/alpha-vantage.js.map +1 -1
- package/dist/providers/coingecko.js +1 -0
- package/dist/providers/coingecko.js.map +1 -1
- package/dist/providers/polymarket.d.ts +2 -0
- package/dist/providers/polymarket.js +163 -0
- package/dist/providers/polymarket.js.map +1 -0
- package/dist/providers/wrap-provider.js +5 -3
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.d.ts +3 -0
- package/dist/providers/yahoo-finance.js +206 -10
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/classify-intent.js +4 -5
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/entity-extractor.js +16 -0
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/route-manifest.js +1 -2
- package/dist/routing/route-manifest.js.map +1 -1
- package/dist/routing/router-llm-client.js +18 -3
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router-prompt.js +1 -1
- package/dist/routing/router.d.ts +1 -1
- package/dist/routing/router.js +309 -9
- package/dist/routing/router.js.map +1 -1
- package/dist/routing/slot-resolver.d.ts +1 -0
- package/dist/routing/slot-resolver.js +1 -1
- package/dist/routing/slot-resolver.js.map +1 -1
- package/dist/runtime/evidence.d.ts +1 -0
- package/dist/runtime/evidence.js.map +1 -1
- package/dist/runtime/numeric-claims.d.ts +23 -0
- package/dist/runtime/numeric-claims.js +100 -0
- package/dist/runtime/numeric-claims.js.map +1 -0
- package/dist/runtime/planning-evidence.js +1 -76
- package/dist/runtime/planning-evidence.js.map +1 -1
- package/dist/runtime/prompt-step.d.ts +10 -1
- package/dist/runtime/prompt-step.js +116 -2
- package/dist/runtime/prompt-step.js.map +1 -1
- package/dist/runtime/session-coordinator.d.ts +11 -1
- package/dist/runtime/session-coordinator.js +354 -62
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/validation.d.ts +3 -1
- package/dist/runtime/workflow-events.d.ts +1 -1
- package/dist/runtime/workflow-events.js.map +1 -1
- package/dist/runtime/workflow-types.d.ts +3 -0
- package/dist/runtime/workflow-types.js.map +1 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -0
- package/dist/tools/fundamentals/comps.js +20 -9
- package/dist/tools/fundamentals/comps.js.map +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.js +190 -76
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/macro/event-probabilities.d.ts +8 -0
- package/dist/tools/macro/event-probabilities.js +121 -0
- package/dist/tools/macro/event-probabilities.js.map +1 -0
- package/dist/tools/market/crypto-price.d.ts +4 -1
- package/dist/tools/market/crypto-price.js +10 -1
- package/dist/tools/market/crypto-price.js.map +1 -1
- package/dist/tools/market/screen-stocks.js +14 -3
- package/dist/tools/market/screen-stocks.js.map +1 -1
- package/dist/tools/market/stock-quote.d.ts +4 -1
- package/dist/tools/market/stock-quote.js +9 -4
- package/dist/tools/market/stock-quote.js.map +1 -1
- package/dist/tools/options/option-chain.d.ts +4 -1
- package/dist/tools/options/option-chain.js +12 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/daily-report.js +10 -4
- package/dist/tools/portfolio/daily-report.js.map +1 -1
- package/dist/tools/portfolio/risk-analysis.js +1 -1
- package/dist/tools/portfolio/risk-analysis.js.map +1 -1
- package/dist/tools/technical/backtest.d.ts +23 -5
- package/dist/tools/technical/backtest.js +131 -94
- package/dist/tools/technical/backtest.js.map +1 -1
- package/dist/tools/technical/indicators.js +1 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/types/fundamentals.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/market.d.ts +2 -0
- package/dist/types/options.d.ts +1 -0
- package/dist/types/prediction-markets.d.ts +13 -0
- package/dist/types/prediction-markets.js +2 -0
- package/dist/types/prediction-markets.js.map +1 -0
- package/gui/server/chat-event-adapter.ts +89 -5
- package/gui/server/http-routes.ts +661 -37
- package/gui/server/invoke-tool.ts +165 -16
- package/gui/server/live-chat-event-adapter.ts +37 -2
- package/gui/server/local-session-coordinator.ts +97 -0
- package/gui/server/market-state-api.ts +64 -48
- package/gui/server/projector.ts +132 -3
- package/gui/server/server.ts +51 -6
- package/gui/server/session-actions.ts +146 -45
- package/gui/server/session-entry-wait.ts +22 -3
- package/gui/server/tool-metadata.ts +3 -1
- package/gui/server/ws-hub.ts +77 -10
- package/gui/shared/chat-events.ts +10 -1
- package/gui/shared/event-reducer.ts +1 -0
- package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
- package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
- package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +13 -15
- package/src/analysts/orchestrator.ts +35 -56
- package/src/cli-main.ts +253 -13
- package/src/config.ts +12 -20
- package/src/doctor/report.ts +1 -1
- package/src/infra/cache.ts +24 -11
- package/src/infra/freshness.ts +165 -0
- package/src/infra/market-calendar.ts +193 -0
- package/src/infra/rate-limiter.ts +1 -0
- package/src/market-state/daily-report.ts +6 -16
- package/src/market-state/service.ts +0 -136
- package/src/market-state/summaries.ts +75 -0
- package/src/memory/sqlite.ts +23 -19
- package/src/onboarding/providers.ts +29 -1
- package/src/pi/opencandle-extension.ts +67 -270
- package/src/pi/session-action-dedupe.ts +155 -0
- package/src/pi/session-writer-lock.ts +290 -20
- package/src/pi/setup.ts +6 -6
- package/src/pi/tui-session-coordinator.ts +351 -0
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/providers/alpha-vantage.ts +6 -0
- package/src/providers/coingecko.ts +2 -0
- package/src/providers/polymarket.ts +214 -0
- package/src/providers/wrap-provider.ts +5 -3
- package/src/providers/yahoo-finance.ts +339 -27
- package/src/routing/classify-intent.ts +4 -5
- package/src/routing/entity-extractor.ts +17 -0
- package/src/routing/route-manifest.ts +1 -2
- package/src/routing/router-llm-client.ts +31 -19
- package/src/routing/router-prompt.ts +1 -1
- package/src/routing/router.ts +379 -8
- package/src/routing/slot-resolver.ts +1 -1
- package/src/runtime/evidence.ts +1 -0
- package/src/runtime/numeric-claims.ts +108 -0
- package/src/runtime/planning-evidence.ts +6 -91
- package/src/runtime/prompt-step.ts +127 -2
- package/src/runtime/session-coordinator.ts +464 -74
- package/src/runtime/validation.ts +1 -1
- package/src/runtime/workflow-events.ts +2 -0
- package/src/runtime/workflow-types.ts +3 -0
- package/src/system-prompt.ts +1 -1
- package/src/tools/AGENTS.md +1 -1
- package/src/tools/fundamentals/comps.ts +25 -14
- package/src/tools/fundamentals/dcf.ts +217 -87
- package/src/tools/index.ts +3 -3
- package/src/tools/macro/event-probabilities.ts +141 -0
- package/src/tools/market/crypto-price.ts +14 -2
- package/src/tools/market/screen-stocks.ts +14 -3
- package/src/tools/market/stock-quote.ts +49 -43
- package/src/tools/options/option-chain.ts +16 -2
- package/src/tools/portfolio/daily-report.ts +10 -4
- package/src/tools/portfolio/risk-analysis.ts +1 -1
- package/src/tools/technical/backtest.ts +167 -108
- package/src/tools/technical/indicators.ts +1 -1
- package/src/types/fundamentals.ts +1 -0
- package/src/types/index.ts +1 -0
- package/src/types/market.ts +2 -0
- package/src/types/options.ts +1 -0
- package/src/types/prediction-markets.ts +13 -0
- package/dist/tools/portfolio/predictions.d.ts +0 -55
- package/dist/tools/portfolio/predictions.js +0 -422
- package/dist/tools/portfolio/predictions.js.map +0 -1
- package/gui/server/chat-run-session.ts +0 -16
- package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
- package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
- package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
- package/src/tools/portfolio/predictions.ts +0 -553
package/gui/server/projector.ts
CHANGED
|
@@ -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(
|
|
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 ===
|
|
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
|
}
|
package/gui/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 (
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|