opencandle 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -21
- package/dist/cli-main.d.ts +1 -0
- package/dist/cli-main.js +224 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +13 -229
- package/dist/cli.js.map +1 -1
- package/dist/doctor/cli-command.d.ts +1 -0
- package/dist/doctor/cli-command.js +69 -0
- package/dist/doctor/cli-command.js.map +1 -0
- package/dist/doctor/render.d.ts +2 -0
- package/dist/doctor/render.js +32 -0
- package/dist/doctor/render.js.map +1 -0
- package/dist/doctor/report.d.ts +63 -0
- package/dist/doctor/report.js +500 -0
- package/dist/doctor/report.js.map +1 -0
- package/dist/infra/native-dependencies.d.ts +8 -0
- package/dist/infra/native-dependencies.js +49 -0
- package/dist/infra/native-dependencies.js.map +1 -1
- package/dist/infra/node-version.js +2 -5
- package/dist/infra/node-version.js.map +1 -1
- package/dist/monitor.d.ts +1 -1
- package/dist/monitor.js +2 -1
- package/dist/monitor.js.map +1 -1
- package/dist/onboarding/provider-status.d.ts +3 -5
- package/dist/onboarding/provider-status.js +5 -34
- package/dist/onboarding/provider-status.js.map +1 -1
- package/dist/pi/session-writer-lock.d.ts +30 -0
- package/dist/pi/session-writer-lock.js +111 -0
- package/dist/pi/session-writer-lock.js.map +1 -0
- package/dist/pi/session.d.ts +0 -1
- package/dist/pi/session.js +2 -1
- package/dist/pi/session.js.map +1 -1
- package/dist/prompts/policy-cards.js +8 -4
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/prompts/workflow-prompts.js +25 -6
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/external-tool-command.d.ts +17 -0
- package/dist/providers/external-tool-command.js +114 -0
- package/dist/providers/external-tool-command.js.map +1 -0
- package/dist/providers/reddit-cli.d.ts +2 -6
- package/dist/providers/reddit-cli.js +4 -33
- package/dist/providers/reddit-cli.js.map +1 -1
- package/dist/providers/twitter-cli.d.ts +2 -6
- package/dist/providers/twitter-cli.js +4 -33
- package/dist/providers/twitter-cli.js.map +1 -1
- package/dist/routing/classify-intent.js +15 -12
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/entity-extractor.js +1 -1
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/planning.js +9 -6
- package/dist/routing/planning.js.map +1 -1
- package/dist/routing/router-llm-client.d.ts +1 -1
- package/dist/routing/router-llm-client.js +1 -1
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router.js +44 -3
- package/dist/routing/router.js.map +1 -1
- package/dist/workflows/compare-assets.js +4 -1
- package/dist/workflows/compare-assets.js.map +1 -1
- package/gui/server/ask-user-bridge.ts +26 -19
- package/gui/server/chat-event-adapter.ts +49 -3
- package/gui/server/http-routes.ts +577 -0
- package/gui/server/invoke-tool.ts +102 -7
- package/gui/server/live-chat-event-adapter.ts +16 -4
- package/gui/server/server.ts +70 -296
- package/gui/server/session-actions.ts +7 -1
- package/gui/server/session-entry-wait.ts +79 -0
- package/gui/server/writer-lock.ts +1 -122
- package/gui/server/ws-hub.ts +25 -0
- package/gui/shared/chat-events.ts +42 -19
- package/gui/shared/event-reducer.ts +41 -26
- package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +1 -0
- package/gui/web/dist/assets/index-DdMnIvZ7.css +2 -0
- package/gui/web/dist/assets/index-YlMaahAS.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +23 -19
- package/src/cli-main.ts +270 -0
- package/src/cli.ts +14 -267
- package/src/doctor/cli-command.ts +83 -0
- package/src/doctor/render.ts +37 -0
- package/src/doctor/report.ts +638 -0
- package/src/infra/native-dependencies.ts +67 -0
- package/src/infra/node-version.ts +2 -5
- package/src/monitor.ts +3 -1
- package/src/onboarding/provider-status.ts +10 -38
- package/src/pi/session-writer-lock.ts +156 -0
- package/src/pi/session.ts +2 -1
- package/src/prompts/policy-cards.ts +12 -4
- package/src/prompts/workflow-prompts.ts +28 -6
- package/src/providers/external-tool-command.ts +164 -0
- package/src/providers/reddit-cli.ts +10 -41
- package/src/providers/twitter-cli.ts +10 -41
- package/src/routing/classify-intent.ts +22 -17
- package/src/routing/entity-extractor.ts +1 -1
- package/src/routing/planning.ts +15 -10
- package/src/routing/router-llm-client.ts +2 -1
- package/src/routing/router.ts +57 -6
- package/src/workflows/compare-assets.ts +4 -2
- package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +0 -1
- package/gui/web/dist/assets/index-C6W_2eAn.js +0 -69
- package/gui/web/dist/assets/index-hwbx24a5.css +0 -1
|
@@ -8,6 +8,12 @@ import { wrapWithDefaults } from "../../src/runtime/tool-defaults-wrapper.js";
|
|
|
8
8
|
import { getAllTools } from "../../src/tools/index.js";
|
|
9
9
|
import type { AskUserHandler } from "../../src/types/index.js";
|
|
10
10
|
import { buildToolInvokeAckMessage } from "./tool-invoke-ack.js";
|
|
11
|
+
import {
|
|
12
|
+
acquireWriterLock,
|
|
13
|
+
refreshWriterLock,
|
|
14
|
+
releaseWriterLock,
|
|
15
|
+
writerLockScopeForSession,
|
|
16
|
+
} from "./writer-lock.js";
|
|
11
17
|
|
|
12
18
|
export interface InvokeToolResult {
|
|
13
19
|
toolCallId: string;
|
|
@@ -20,7 +26,11 @@ interface ToolInvokeClient {
|
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export interface ToolInvokeController {
|
|
23
|
-
handleToolInvoke(
|
|
29
|
+
handleToolInvoke(
|
|
30
|
+
toolName: string,
|
|
31
|
+
args: Record<string, unknown>,
|
|
32
|
+
sessionId?: string,
|
|
33
|
+
): Promise<InvokeToolResult>;
|
|
24
34
|
handleToolInvokeMessage(client: ToolInvokeClient, data: Record<string, unknown>): Promise<void>;
|
|
25
35
|
}
|
|
26
36
|
|
|
@@ -32,6 +42,10 @@ export interface ToolInvokeControllerOptions {
|
|
|
32
42
|
getTools?: typeof getAllTools;
|
|
33
43
|
invokeTool?: typeof invokeToolFromUi;
|
|
34
44
|
askUserHandler?: AskUserHandler;
|
|
45
|
+
askUserHandlerForSessionId?: (sessionId: string) => AskUserHandler;
|
|
46
|
+
resolveSessionManager?: (sessionId: string) => Promise<SessionManager | null>;
|
|
47
|
+
broadcastSessionSnapshot?: (sessionManager: SessionManager) => void;
|
|
48
|
+
broadcastSessions?: () => void;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export function createToolInvokeController({
|
|
@@ -42,20 +56,63 @@ export function createToolInvokeController({
|
|
|
42
56
|
getTools = getAllTools,
|
|
43
57
|
invokeTool = invokeToolFromUi,
|
|
44
58
|
askUserHandler,
|
|
59
|
+
askUserHandlerForSessionId,
|
|
60
|
+
resolveSessionManager,
|
|
61
|
+
broadcastSessionSnapshot,
|
|
62
|
+
broadcastSessions,
|
|
45
63
|
}: ToolInvokeControllerOptions): ToolInvokeController {
|
|
46
64
|
async function handleToolInvoke(
|
|
47
65
|
toolName: string,
|
|
48
66
|
args: Record<string, unknown>,
|
|
67
|
+
sessionId = "",
|
|
49
68
|
): Promise<InvokeToolResult> {
|
|
50
69
|
if (role !== "writer") throw new Error("Read-only follower mode");
|
|
51
70
|
const tool = getTools().find((candidate) => candidate.name === toolName);
|
|
52
71
|
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
|
|
73
|
+
const currentSessionManager = getSessionManager();
|
|
74
|
+
const runSessionManager = await resolveToolInvokeSessionManager(
|
|
75
|
+
currentSessionManager,
|
|
76
|
+
sessionId,
|
|
77
|
+
resolveSessionManager,
|
|
78
|
+
);
|
|
79
|
+
const useCurrentSession = sameSessionStorage(currentSessionManager, runSessionManager);
|
|
80
|
+
let acquiredLockScope = "";
|
|
81
|
+
let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
82
|
+
if (!useCurrentSession) {
|
|
83
|
+
const lockScope = writerLockScopeForSession(runSessionManager);
|
|
84
|
+
const lockResult = await acquireWriterLock(lockScope, "gui");
|
|
85
|
+
if (lockResult.role !== "writer") {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Session is currently being written by ${lockResult.lock.processKind} (pid ${lockResult.lock.pid}).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
acquiredLockScope = lockScope;
|
|
91
|
+
lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const runSessionId = safeSessionId(runSessionManager);
|
|
96
|
+
const result = await invokeTool(runSessionManager, tool, args, "ui", {
|
|
97
|
+
askUserHandler:
|
|
98
|
+
runSessionId && askUserHandlerForSessionId
|
|
99
|
+
? askUserHandlerForSessionId(runSessionId)
|
|
100
|
+
: askUserHandler,
|
|
101
|
+
});
|
|
102
|
+
if (!result.isError && marketStateToolMapping(toolName) != null) {
|
|
103
|
+
onMarketStateChanged?.();
|
|
104
|
+
}
|
|
105
|
+
if (useCurrentSession) {
|
|
106
|
+
broadcastState();
|
|
107
|
+
} else {
|
|
108
|
+
broadcastSessionSnapshot?.(runSessionManager);
|
|
109
|
+
broadcastSessions?.();
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
} finally {
|
|
113
|
+
if (lockHeartbeat) clearInterval(lockHeartbeat);
|
|
114
|
+
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
56
115
|
}
|
|
57
|
-
broadcastState();
|
|
58
|
-
return result;
|
|
59
116
|
}
|
|
60
117
|
|
|
61
118
|
async function handleToolInvokeMessage(
|
|
@@ -64,8 +121,9 @@ export function createToolInvokeController({
|
|
|
64
121
|
): Promise<void> {
|
|
65
122
|
const requestId = typeof data.requestId === "string" ? data.requestId : "";
|
|
66
123
|
const toolName = String(data.toolName ?? "");
|
|
124
|
+
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
|
|
67
125
|
try {
|
|
68
|
-
const result = await handleToolInvoke(toolName, requestArgs(data.args));
|
|
126
|
+
const result = await handleToolInvoke(toolName, requestArgs(data.args), sessionId);
|
|
69
127
|
if (requestId) {
|
|
70
128
|
client.send(buildToolInvokeAckMessage(requestId, toolName, result));
|
|
71
129
|
}
|
|
@@ -88,6 +146,43 @@ export function createToolInvokeController({
|
|
|
88
146
|
return { handleToolInvoke, handleToolInvokeMessage };
|
|
89
147
|
}
|
|
90
148
|
|
|
149
|
+
function sameSessionStorage(current: SessionManager, target: SessionManager): boolean {
|
|
150
|
+
if (current === target) return true;
|
|
151
|
+
const currentFile = safeSessionFile(current);
|
|
152
|
+
const targetFile = safeSessionFile(target);
|
|
153
|
+
return Boolean(currentFile && targetFile && currentFile === targetFile);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function safeSessionFile(sessionManager: SessionManager): string {
|
|
157
|
+
try {
|
|
158
|
+
return sessionManager.getSessionFile() ?? "";
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeSessionId(sessionManager: SessionManager): string {
|
|
165
|
+
try {
|
|
166
|
+
return sessionManager.getSessionId();
|
|
167
|
+
} catch {
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function resolveToolInvokeSessionManager(
|
|
173
|
+
currentSessionManager: SessionManager,
|
|
174
|
+
sessionId: string,
|
|
175
|
+
resolveSessionManager: ((sessionId: string) => Promise<SessionManager | null>) | undefined,
|
|
176
|
+
): Promise<SessionManager> {
|
|
177
|
+
const targetSessionId = sessionId.trim();
|
|
178
|
+
if (!targetSessionId || targetSessionId === safeSessionId(currentSessionManager)) {
|
|
179
|
+
return currentSessionManager;
|
|
180
|
+
}
|
|
181
|
+
const resolved = await resolveSessionManager?.(targetSessionId);
|
|
182
|
+
if (!resolved) throw new Error("Unknown saved session");
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
|
|
91
186
|
function requestArgs(value: unknown): Record<string, unknown> {
|
|
92
187
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
93
188
|
}
|
|
@@ -29,15 +29,20 @@ export function createLiveChatEventAdapter(
|
|
|
29
29
|
let lastAssistantMessageId: string | undefined;
|
|
30
30
|
const completedMessageIds = new Set<string>();
|
|
31
31
|
|
|
32
|
-
const emit = (event: Omit<ChatEvent, "seq">) => {
|
|
33
|
-
options.emit({ ...event, seq: seq++ } as ChatEvent);
|
|
32
|
+
const emit = (event: Omit<ChatEvent, "seq" | "sessionId">) => {
|
|
33
|
+
options.emit({ ...event, sessionId: options.sessionId, seq: seq++ } as ChatEvent);
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const ensureAssistantMessage = (): string => {
|
|
37
37
|
if (currentAssistantMessageId) return currentAssistantMessageId;
|
|
38
38
|
currentAssistantMessageId = `${options.runId}-assistant-${++assistantCount}`;
|
|
39
39
|
lastAssistantMessageId = currentAssistantMessageId;
|
|
40
|
-
emit({
|
|
40
|
+
emit({
|
|
41
|
+
type: "message.created",
|
|
42
|
+
runId: options.runId,
|
|
43
|
+
messageId: currentAssistantMessageId,
|
|
44
|
+
role: "assistant",
|
|
45
|
+
});
|
|
41
46
|
return currentAssistantMessageId;
|
|
42
47
|
};
|
|
43
48
|
|
|
@@ -49,6 +54,7 @@ export function createLiveChatEventAdapter(
|
|
|
49
54
|
completedMessageIds.add(messageId);
|
|
50
55
|
emit({
|
|
51
56
|
type: "message.completed",
|
|
57
|
+
runId: options.runId,
|
|
52
58
|
messageId,
|
|
53
59
|
content: contentToChatContent(message.content),
|
|
54
60
|
});
|
|
@@ -66,9 +72,10 @@ export function createLiveChatEventAdapter(
|
|
|
66
72
|
userCount === 1 && options.originalPrompt
|
|
67
73
|
? options.originalPrompt
|
|
68
74
|
: messageText(message.content);
|
|
69
|
-
emit({ type: "message.created", messageId, role: "user" });
|
|
75
|
+
emit({ type: "message.created", runId: options.runId, messageId, role: "user" });
|
|
70
76
|
emit({
|
|
71
77
|
type: "message.completed",
|
|
78
|
+
runId: options.runId,
|
|
72
79
|
messageId,
|
|
73
80
|
content: [{ type: "text", text }],
|
|
74
81
|
});
|
|
@@ -85,6 +92,7 @@ export function createLiveChatEventAdapter(
|
|
|
85
92
|
if (update.type === "text_delta") {
|
|
86
93
|
emit({
|
|
87
94
|
type: "message.delta",
|
|
95
|
+
runId: options.runId,
|
|
88
96
|
messageId: ensureAssistantMessage(),
|
|
89
97
|
text: update.delta,
|
|
90
98
|
});
|
|
@@ -116,6 +124,7 @@ export function createLiveChatEventAdapter(
|
|
|
116
124
|
const messageId = messageIdForTool();
|
|
117
125
|
emit({
|
|
118
126
|
type: "tool.started",
|
|
127
|
+
runId: options.runId,
|
|
119
128
|
toolCallId: event.toolCallId,
|
|
120
129
|
messageId,
|
|
121
130
|
name: event.toolName,
|
|
@@ -127,6 +136,7 @@ export function createLiveChatEventAdapter(
|
|
|
127
136
|
case "tool_execution_update":
|
|
128
137
|
emit({
|
|
129
138
|
type: "tool.delta",
|
|
139
|
+
runId: options.runId,
|
|
130
140
|
toolCallId: event.toolCallId,
|
|
131
141
|
chunk: event.partialResult,
|
|
132
142
|
});
|
|
@@ -137,6 +147,7 @@ export function createLiveChatEventAdapter(
|
|
|
137
147
|
if (event.isError) {
|
|
138
148
|
emit({
|
|
139
149
|
type: "tool.failed",
|
|
150
|
+
runId: options.runId,
|
|
140
151
|
toolCallId: event.toolCallId,
|
|
141
152
|
error: {
|
|
142
153
|
message: messageText(output.content),
|
|
@@ -146,6 +157,7 @@ export function createLiveChatEventAdapter(
|
|
|
146
157
|
} else {
|
|
147
158
|
emit({
|
|
148
159
|
type: "tool.completed",
|
|
160
|
+
runId: options.runId,
|
|
149
161
|
toolCallId: event.toolCallId,
|
|
150
162
|
output,
|
|
151
163
|
});
|
package/gui/server/server.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { extname, join, resolve } from "node:path";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { resolve } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import {
|
|
7
6
|
AuthStorage,
|
|
@@ -9,12 +8,10 @@ import {
|
|
|
9
8
|
createAgentSessionServices,
|
|
10
9
|
getAgentDir,
|
|
11
10
|
ModelRegistry,
|
|
12
|
-
SessionManager,
|
|
13
11
|
SettingsManager,
|
|
14
12
|
} from "@earendil-works/pi-coding-agent";
|
|
15
13
|
import { createOpenCandleSession } from "../../src/index.js";
|
|
16
|
-
import {
|
|
17
|
-
import type { ChatEvent } from "../shared/chat-events.js";
|
|
14
|
+
import { assertSupportedNodeVersion } from "../../src/infra/node-version.js";
|
|
18
15
|
import { createAskUserBridge } from "./ask-user-bridge.js";
|
|
19
16
|
import {
|
|
20
17
|
createLocalAutomationHeartbeat,
|
|
@@ -25,26 +22,25 @@ import {
|
|
|
25
22
|
BackgroundQuoteRefreshes,
|
|
26
23
|
createBackgroundQuotePoller,
|
|
27
24
|
} from "./background-quotes.js";
|
|
28
|
-
import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
|
|
29
|
-
import { chatRunSessionConflict } from "./chat-run-session.js";
|
|
30
25
|
import { createInitialGuiSessionManager } from "./gui-session-manager.js";
|
|
26
|
+
import { createHttpRequestHandler, resolveSessionManagerById } from "./http-routes.js";
|
|
31
27
|
import { createToolInvokeController } from "./invoke-tool.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
buildMarketStateSnapshot,
|
|
36
|
-
searchInstrumentCandidates,
|
|
37
|
-
} from "./market-state-api.js";
|
|
38
|
-
import { buildModelSetupState, createModelSetupController } from "./model-setup.js";
|
|
39
|
-
import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
|
|
40
|
-
import { createPromptObservation, observePromptEvent } from "./prompt-observation.js";
|
|
28
|
+
import { buildMarketStateQuoteSnapshot } from "./market-state-api.js";
|
|
29
|
+
import { createModelSetupController } from "./model-setup.js";
|
|
30
|
+
import { isTrustedPrivateApiRequest } from "./private-api-access.js";
|
|
41
31
|
import { QuoteSnapshotStore } from "./quote-snapshot-store.js";
|
|
42
|
-
import { createSessionActionsController
|
|
43
|
-
import { waitForNewEntryId } from "./session-entry-wait.js";
|
|
32
|
+
import { createSessionActionsController } from "./session-actions.js";
|
|
44
33
|
import { createGracefulShutdown } from "./shutdown.js";
|
|
45
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
acquireWriterLock,
|
|
36
|
+
refreshWriterLock,
|
|
37
|
+
releaseWriterLock,
|
|
38
|
+
writerLockScopeForSession,
|
|
39
|
+
} from "./writer-lock.js";
|
|
46
40
|
import { createWsHub, type WsHub } from "./ws-hub.js";
|
|
47
41
|
|
|
42
|
+
assertSupportedNodeVersion();
|
|
43
|
+
|
|
48
44
|
const cwd = process.cwd();
|
|
49
45
|
const host = process.env.OPENCANDLE_GUI_HOST ?? "127.0.0.1";
|
|
50
46
|
const port = Number(process.env.OPENCANDLE_GUI_PORT ?? 14567);
|
|
@@ -63,7 +59,9 @@ const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
|
63
59
|
const initialSessionManager = createInitialGuiSessionManager(cwd);
|
|
64
60
|
let sessionManager = initialSessionManager;
|
|
65
61
|
const sessionDir = sessionManager.getSessionDir();
|
|
66
|
-
const
|
|
62
|
+
const initialWriterLockScope = writerLockScopeForSession(sessionManager);
|
|
63
|
+
const lockResult = await acquireWriterLock(initialWriterLockScope, "gui");
|
|
64
|
+
let activeWriterLockScope = initialWriterLockScope;
|
|
67
65
|
let wsHub: WsHub;
|
|
68
66
|
let quotePoller: BackgroundQuotePoller;
|
|
69
67
|
const askUserBridge = createAskUserBridge({
|
|
@@ -93,7 +91,7 @@ const runtime = await createAgentSessionRuntime(
|
|
|
93
91
|
{ cwd, agentDir, sessionManager },
|
|
94
92
|
);
|
|
95
93
|
let session = runtime.session;
|
|
96
|
-
const heartbeat = setInterval(() => refreshWriterLock(
|
|
94
|
+
const heartbeat = setInterval(() => refreshWriterLock(activeWriterLockScope), 5000);
|
|
97
95
|
const backgroundQuoteRefreshes = new BackgroundQuoteRefreshes();
|
|
98
96
|
const quoteSnapshotStore = new QuoteSnapshotStore(() => buildMarketStateQuoteSnapshot());
|
|
99
97
|
quotePoller = createBackgroundQuotePoller({
|
|
@@ -117,8 +115,17 @@ const toolInvokeController = createToolInvokeController({
|
|
|
117
115
|
role: lockResult.role,
|
|
118
116
|
getSessionManager: () => sessionManager,
|
|
119
117
|
broadcastState: () => wsHub.broadcastState(),
|
|
118
|
+
broadcastSessionSnapshot: (targetSessionManager) =>
|
|
119
|
+
wsHub.broadcastSessionSnapshot(targetSessionManager),
|
|
120
|
+
broadcastSessions: () => wsHub.broadcastSessions(),
|
|
120
121
|
onMarketStateChanged: () => quoteSnapshotStore.invalidate(),
|
|
121
122
|
askUserHandler: askUserBridge.ask,
|
|
123
|
+
askUserHandlerForSessionId: (sessionId) => askUserBridge.askForSession(sessionId),
|
|
124
|
+
resolveSessionManager: (sessionId) =>
|
|
125
|
+
resolveSessionManagerById(
|
|
126
|
+
{ cwd, sessionDir, getSessionManager: () => sessionManager },
|
|
127
|
+
sessionId,
|
|
128
|
+
),
|
|
122
129
|
});
|
|
123
130
|
const sessionActionsController = createSessionActionsController({
|
|
124
131
|
role: lockResult.role,
|
|
@@ -154,119 +161,54 @@ wsHub = createWsHub({
|
|
|
154
161
|
|
|
155
162
|
let unsubscribeSession = wsHub.subscribeToSessionEvents();
|
|
156
163
|
runtime.setRebindSession(async (nextSession) => {
|
|
164
|
+
const nextWriterLockScope = writerLockScopeForSession(nextSession.sessionManager);
|
|
165
|
+
if (lockResult.role === "writer" && nextWriterLockScope !== activeWriterLockScope) {
|
|
166
|
+
const nextLockResult = await acquireWriterLock(nextWriterLockScope, "gui");
|
|
167
|
+
if (nextLockResult.role !== "writer") {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Session is currently being written by ${nextLockResult.lock.processKind} (pid ${nextLockResult.lock.pid}).`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
releaseWriterLock(activeWriterLockScope);
|
|
173
|
+
activeWriterLockScope = nextWriterLockScope;
|
|
174
|
+
}
|
|
157
175
|
unsubscribeSession();
|
|
158
176
|
session = nextSession;
|
|
159
177
|
sessionManager = nextSession.sessionManager;
|
|
160
178
|
unsubscribeSession = wsHub.subscribeToSessionEvents();
|
|
161
179
|
});
|
|
162
180
|
|
|
163
|
-
const
|
|
164
|
-
|
|
181
|
+
const httpRequestHandler = createHttpRequestHandler({
|
|
182
|
+
host,
|
|
183
|
+
port,
|
|
184
|
+
webDist,
|
|
185
|
+
role: lockResult.role,
|
|
186
|
+
cwd,
|
|
187
|
+
agentDir,
|
|
188
|
+
sessionDir,
|
|
189
|
+
privateApiSessionToken,
|
|
190
|
+
allowRemotePrivateApi,
|
|
191
|
+
getSession: () => session,
|
|
192
|
+
getSessionManager: () => sessionManager,
|
|
193
|
+
createSessionForManager: async (targetSessionManager) =>
|
|
194
|
+
createOpenCandleSession({
|
|
195
|
+
cwd,
|
|
196
|
+
agentDir,
|
|
197
|
+
authStorage,
|
|
198
|
+
modelRegistry,
|
|
199
|
+
settingsManager,
|
|
200
|
+
sessionManager: targetSessionManager,
|
|
201
|
+
askUserHandler: askUserBridge.askForSession(targetSessionManager.getSessionId()),
|
|
202
|
+
}),
|
|
203
|
+
wsHub,
|
|
204
|
+
modelSetupController,
|
|
205
|
+
sessionActionsController,
|
|
206
|
+
quoteSnapshotStore,
|
|
165
207
|
});
|
|
166
208
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
writeJson(res, { ok: true, role: lockResult.role });
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (url.pathname === "/api/bootstrap" && req.method === "GET") {
|
|
175
|
-
if (!allowTrustedGuiRequest(req, res, "Bootstrap API")) return;
|
|
176
|
-
writeJson(res, await wsHub.buildBootstrapPayload());
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (url.pathname === "/api/session/new" && req.method === "POST") {
|
|
181
|
-
if (!allowTrustedGuiRequest(req, res, "Session API")) return;
|
|
182
|
-
if (lockResult.role !== "writer") {
|
|
183
|
-
writeJson(res, { error: "Read-only follower mode" }, 409);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
await sessionActionsController.handleNewSession();
|
|
187
|
-
wsHub.broadcastState();
|
|
188
|
-
wsHub.broadcastSessions();
|
|
189
|
-
writeJson(res, await wsHub.buildBootstrapPayload());
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
194
|
-
if (!allowTrustedGuiRequest(req, res, "Session API")) return;
|
|
195
|
-
writeJson(res, {
|
|
196
|
-
currentSessionId: sessionManager.getSessionId(),
|
|
197
|
-
role: lockResult.role,
|
|
198
|
-
sessions: await SessionManager.list(cwd, sessionDir),
|
|
199
|
-
});
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (url.pathname === "/api/session/events" && req.method === "GET") {
|
|
204
|
-
if (!allowTrustedGuiRequest(req, res, "Session API")) return;
|
|
205
|
-
writeJson(res, {
|
|
206
|
-
sessionId: sessionManager.getSessionId(),
|
|
207
|
-
role: lockResult.role,
|
|
208
|
-
events: wsHub.currentChatEvents(),
|
|
209
|
-
});
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (url.pathname === "/api/market-state" && req.method === "GET") {
|
|
214
|
-
if (!allowTrustedGuiRequest(req, res, "Market-state API")) return;
|
|
215
|
-
writeJson(res, buildMarketStateSnapshot());
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (url.pathname === "/api/market-state/quotes" && req.method === "GET") {
|
|
220
|
-
if (!allowTrustedGuiRequest(req, res, "Market-state API")) return;
|
|
221
|
-
writeJson(res, await quoteSnapshotStore.get());
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (url.pathname === "/api/instruments/search" && req.method === "GET") {
|
|
226
|
-
if (!allowTrustedGuiRequest(req, res, "Market-state API")) return;
|
|
227
|
-
writeJson(res, await searchInstrumentCandidates(url.searchParams.get("q") ?? ""));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (url.pathname === "/api/diagnostics/twitter-cli" && req.method === "GET") {
|
|
232
|
-
if (!allowTrustedGuiRequest(req, res, "Diagnostics API")) return;
|
|
233
|
-
const mode = url.searchParams.get("mode") === "session" ? "session" : "install";
|
|
234
|
-
const force = url.searchParams.get("force") === "1";
|
|
235
|
-
writeJson(res, await probeProviderStatus("twitter", { mode, force }));
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (url.pathname === "/api/diagnostics/reddit-cli" && req.method === "GET") {
|
|
240
|
-
if (!allowTrustedGuiRequest(req, res, "Diagnostics API")) return;
|
|
241
|
-
const mode = url.searchParams.get("mode") === "session" ? "session" : "install";
|
|
242
|
-
const force = url.searchParams.get("force") === "1";
|
|
243
|
-
writeJson(res, await probeProviderStatus("reddit", { mode, force }));
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (url.pathname === "/api/chat/run" && req.method === "POST") {
|
|
248
|
-
if (!allowTrustedGuiRequest(req, res, "Chat run API")) return;
|
|
249
|
-
await handleSseChatRun(req, res);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const requested = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
254
|
-
const path = resolve(join(webDist, requested));
|
|
255
|
-
if (!path.startsWith(webDist) || !existsSync(path)) {
|
|
256
|
-
const fallback = resolve(join(webDist, "index.html"));
|
|
257
|
-
if (!extname(requested) && fallback.startsWith(webDist) && existsSync(fallback)) {
|
|
258
|
-
res.writeHead(200, privateGuiHeaders("text/html; charset=utf-8"));
|
|
259
|
-
createReadStream(fallback).pipe(res);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
res.writeHead(404).end("Not found");
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const type = contentType(path);
|
|
267
|
-
res.writeHead(200, privateGuiHeaders(type));
|
|
268
|
-
createReadStream(path).pipe(res);
|
|
269
|
-
}
|
|
209
|
+
const server = createServer((req, res) => {
|
|
210
|
+
void httpRequestHandler(req, res);
|
|
211
|
+
});
|
|
270
212
|
|
|
271
213
|
server.on("upgrade", (req, socket) => wsHub.handleUpgrade(req, socket));
|
|
272
214
|
|
|
@@ -292,7 +234,7 @@ const shutdown = createGracefulShutdown({
|
|
|
292
234
|
localAutomationHeartbeat.stop();
|
|
293
235
|
wsHub.closeClients();
|
|
294
236
|
unsubscribeSession();
|
|
295
|
-
releaseWriterLock(
|
|
237
|
+
releaseWriterLock(activeWriterLockScope);
|
|
296
238
|
await runtime.dispose();
|
|
297
239
|
},
|
|
298
240
|
exit: (code) => process.exit(code),
|
|
@@ -300,171 +242,3 @@ const shutdown = createGracefulShutdown({
|
|
|
300
242
|
|
|
301
243
|
process.once("SIGINT", shutdown);
|
|
302
244
|
process.once("SIGTERM", shutdown);
|
|
303
|
-
|
|
304
|
-
async function handleSseChatRun(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
305
|
-
if (lockResult.role !== "writer") {
|
|
306
|
-
writeJson(res, { error: "Read-only follower mode" }, 409);
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const body = await readJsonBody(req);
|
|
311
|
-
const prompt = String(asRecord(body).prompt ?? "").trim();
|
|
312
|
-
if (!prompt) {
|
|
313
|
-
writeJson(res, { error: "prompt is required" }, 400);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const sessionConflict = chatRunSessionConflict(
|
|
318
|
-
asRecord(body).sessionId,
|
|
319
|
-
sessionManager.getSessionId(),
|
|
320
|
-
);
|
|
321
|
-
if (sessionConflict) {
|
|
322
|
-
writeJson(res, sessionConflict, 409);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
res.writeHead(200, {
|
|
327
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
328
|
-
"cache-control": "no-cache, no-transform",
|
|
329
|
-
connection: "keep-alive",
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
let seq = 1;
|
|
333
|
-
const runId = `gui-run-${Date.now()}`;
|
|
334
|
-
const runSession = session;
|
|
335
|
-
const runSessionManager = sessionManager;
|
|
336
|
-
const sessionId = runSessionManager.getSessionId();
|
|
337
|
-
// Name new sessions by the user's words before any workflow transform
|
|
338
|
-
// replaces the turn text, so the sidebar shows what the user actually asked.
|
|
339
|
-
if (!prompt.startsWith("/") && !runSessionManager.getSessionName()) {
|
|
340
|
-
runSessionManager.appendSessionInfo(prompt.length > 80 ? `${prompt.slice(0, 77)}...` : prompt);
|
|
341
|
-
}
|
|
342
|
-
const beforeEntries = runSessionManager.getEntries();
|
|
343
|
-
const beforeCount = beforeEntries.length;
|
|
344
|
-
const beforeIds = new Set(beforeEntries.map((entry) => entry.id));
|
|
345
|
-
writeSse(res, { type: "run.started", runId, sessionId, seq: seq++ });
|
|
346
|
-
res.flushHeaders?.();
|
|
347
|
-
const liveStartSeq = seq;
|
|
348
|
-
const liveAdapter = createLiveChatEventAdapter({
|
|
349
|
-
runId,
|
|
350
|
-
sessionId,
|
|
351
|
-
startSeq: seq,
|
|
352
|
-
emit: (event) => writeSse(res, event),
|
|
353
|
-
originalPrompt: prompt,
|
|
354
|
-
});
|
|
355
|
-
const observation = createPromptObservation();
|
|
356
|
-
const unsubscribeLive = runSession.subscribe((event) => {
|
|
357
|
-
liveAdapter.handle(event);
|
|
358
|
-
observePromptEvent(observation, event);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
|
|
363
|
-
if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
|
|
364
|
-
runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
|
|
365
|
-
const message =
|
|
366
|
-
modelSetup.requirement === "select_model"
|
|
367
|
-
? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
|
|
368
|
-
: "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
|
|
369
|
-
runSessionManager.appendCustomMessageEntry("opencandle-model-setup", message, true, {
|
|
370
|
-
source: "gui",
|
|
371
|
-
requirement: modelSetup.requirement,
|
|
372
|
-
});
|
|
373
|
-
wsHub.broadcastState();
|
|
374
|
-
} else {
|
|
375
|
-
await promptAndSettle(runSession, prompt, beforeIds, observation);
|
|
376
|
-
wsHub.broadcastState();
|
|
377
|
-
}
|
|
378
|
-
seq = liveAdapter.nextSeq();
|
|
379
|
-
if (seq === liveStartSeq) {
|
|
380
|
-
await waitForNewEntryId(
|
|
381
|
-
() => runSessionManager.getEntries().map((entry) => entry.id),
|
|
382
|
-
beforeIds,
|
|
383
|
-
);
|
|
384
|
-
const newEntries = runSessionManager
|
|
385
|
-
.getEntries()
|
|
386
|
-
.slice(beforeCount)
|
|
387
|
-
.filter((entry) => !beforeIds.has(entry.id));
|
|
388
|
-
const events = sessionEntriesToChatEvents(newEntries, {
|
|
389
|
-
sessionId,
|
|
390
|
-
updatedAt: new Date().toISOString(),
|
|
391
|
-
startSeq: seq,
|
|
392
|
-
});
|
|
393
|
-
for (const event of events) {
|
|
394
|
-
writeSse(res, event);
|
|
395
|
-
seq = event.seq + 1;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
writeSse(res, { type: "run.completed", runId, seq });
|
|
399
|
-
} catch (error) {
|
|
400
|
-
seq = liveAdapter.nextSeq();
|
|
401
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
402
|
-
writeSse(res, { type: "run.failed", runId, error: { message }, seq });
|
|
403
|
-
} finally {
|
|
404
|
-
unsubscribeLive();
|
|
405
|
-
res.end();
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function writeJson(res: ServerResponse, value: unknown, status = 200): void {
|
|
410
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
411
|
-
res.end(JSON.stringify(value));
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function allowTrustedGuiRequest(req: IncomingMessage, res: ServerResponse, label: string): boolean {
|
|
415
|
-
if (
|
|
416
|
-
isTrustedPrivateApiRequest(req.headers, privateApiSessionToken, req.socket.remoteAddress, {
|
|
417
|
-
allowRemote: allowRemotePrivateApi,
|
|
418
|
-
})
|
|
419
|
-
)
|
|
420
|
-
return true;
|
|
421
|
-
res.writeHead(403, { "content-type": "application/json" });
|
|
422
|
-
res.end(
|
|
423
|
-
JSON.stringify({
|
|
424
|
-
error: `${label} is only available to trusted GUI browser sessions.`,
|
|
425
|
-
}),
|
|
426
|
-
);
|
|
427
|
-
return false;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function privateGuiHeaders(contentTypeValue: string): Record<string, string> {
|
|
431
|
-
const headers: Record<string, string> = { "content-type": contentTypeValue };
|
|
432
|
-
if (contentTypeValue.startsWith("text/html")) {
|
|
433
|
-
headers["set-cookie"] = privateApiCookieHeader(privateApiSessionToken);
|
|
434
|
-
}
|
|
435
|
-
return headers;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function writeSse(res: ServerResponse, event: ChatEvent): void {
|
|
439
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
443
|
-
const chunks: Buffer[] = [];
|
|
444
|
-
for await (const chunk of req) {
|
|
445
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
446
|
-
}
|
|
447
|
-
if (chunks.length === 0) return {};
|
|
448
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function contentType(path: string): string {
|
|
452
|
-
switch (extname(path)) {
|
|
453
|
-
case ".html":
|
|
454
|
-
return "text/html; charset=utf-8";
|
|
455
|
-
case ".css":
|
|
456
|
-
return "text/css; charset=utf-8";
|
|
457
|
-
case ".js":
|
|
458
|
-
return "text/javascript; charset=utf-8";
|
|
459
|
-
case ".svg":
|
|
460
|
-
return "image/svg+xml";
|
|
461
|
-
default:
|
|
462
|
-
return "application/octet-stream";
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function asRecord(value: unknown): Record<string, unknown> {
|
|
467
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
468
|
-
? (value as Record<string, unknown>)
|
|
469
|
-
: {};
|
|
470
|
-
}
|