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.
Files changed (101) hide show
  1. package/README.md +34 -21
  2. package/dist/cli-main.d.ts +1 -0
  3. package/dist/cli-main.js +224 -0
  4. package/dist/cli-main.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +13 -229
  7. package/dist/cli.js.map +1 -1
  8. package/dist/doctor/cli-command.d.ts +1 -0
  9. package/dist/doctor/cli-command.js +69 -0
  10. package/dist/doctor/cli-command.js.map +1 -0
  11. package/dist/doctor/render.d.ts +2 -0
  12. package/dist/doctor/render.js +32 -0
  13. package/dist/doctor/render.js.map +1 -0
  14. package/dist/doctor/report.d.ts +63 -0
  15. package/dist/doctor/report.js +500 -0
  16. package/dist/doctor/report.js.map +1 -0
  17. package/dist/infra/native-dependencies.d.ts +8 -0
  18. package/dist/infra/native-dependencies.js +49 -0
  19. package/dist/infra/native-dependencies.js.map +1 -1
  20. package/dist/infra/node-version.js +2 -5
  21. package/dist/infra/node-version.js.map +1 -1
  22. package/dist/monitor.d.ts +1 -1
  23. package/dist/monitor.js +2 -1
  24. package/dist/monitor.js.map +1 -1
  25. package/dist/onboarding/provider-status.d.ts +3 -5
  26. package/dist/onboarding/provider-status.js +5 -34
  27. package/dist/onboarding/provider-status.js.map +1 -1
  28. package/dist/pi/session-writer-lock.d.ts +30 -0
  29. package/dist/pi/session-writer-lock.js +111 -0
  30. package/dist/pi/session-writer-lock.js.map +1 -0
  31. package/dist/pi/session.d.ts +0 -1
  32. package/dist/pi/session.js +2 -1
  33. package/dist/pi/session.js.map +1 -1
  34. package/dist/prompts/policy-cards.js +8 -4
  35. package/dist/prompts/policy-cards.js.map +1 -1
  36. package/dist/prompts/workflow-prompts.js +25 -6
  37. package/dist/prompts/workflow-prompts.js.map +1 -1
  38. package/dist/providers/external-tool-command.d.ts +17 -0
  39. package/dist/providers/external-tool-command.js +114 -0
  40. package/dist/providers/external-tool-command.js.map +1 -0
  41. package/dist/providers/reddit-cli.d.ts +2 -6
  42. package/dist/providers/reddit-cli.js +4 -33
  43. package/dist/providers/reddit-cli.js.map +1 -1
  44. package/dist/providers/twitter-cli.d.ts +2 -6
  45. package/dist/providers/twitter-cli.js +4 -33
  46. package/dist/providers/twitter-cli.js.map +1 -1
  47. package/dist/routing/classify-intent.js +15 -12
  48. package/dist/routing/classify-intent.js.map +1 -1
  49. package/dist/routing/entity-extractor.js +1 -1
  50. package/dist/routing/entity-extractor.js.map +1 -1
  51. package/dist/routing/planning.js +9 -6
  52. package/dist/routing/planning.js.map +1 -1
  53. package/dist/routing/router-llm-client.d.ts +1 -1
  54. package/dist/routing/router-llm-client.js +1 -1
  55. package/dist/routing/router-llm-client.js.map +1 -1
  56. package/dist/routing/router.js +44 -3
  57. package/dist/routing/router.js.map +1 -1
  58. package/dist/workflows/compare-assets.js +4 -1
  59. package/dist/workflows/compare-assets.js.map +1 -1
  60. package/gui/server/ask-user-bridge.ts +26 -19
  61. package/gui/server/chat-event-adapter.ts +49 -3
  62. package/gui/server/http-routes.ts +577 -0
  63. package/gui/server/invoke-tool.ts +102 -7
  64. package/gui/server/live-chat-event-adapter.ts +16 -4
  65. package/gui/server/server.ts +70 -296
  66. package/gui/server/session-actions.ts +7 -1
  67. package/gui/server/session-entry-wait.ts +79 -0
  68. package/gui/server/writer-lock.ts +1 -122
  69. package/gui/server/ws-hub.ts +25 -0
  70. package/gui/shared/chat-events.ts +42 -19
  71. package/gui/shared/event-reducer.ts +41 -26
  72. package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +1 -0
  73. package/gui/web/dist/assets/index-DdMnIvZ7.css +2 -0
  74. package/gui/web/dist/assets/index-YlMaahAS.js +65 -0
  75. package/gui/web/dist/index.html +2 -2
  76. package/package.json +23 -19
  77. package/src/cli-main.ts +270 -0
  78. package/src/cli.ts +14 -267
  79. package/src/doctor/cli-command.ts +83 -0
  80. package/src/doctor/render.ts +37 -0
  81. package/src/doctor/report.ts +638 -0
  82. package/src/infra/native-dependencies.ts +67 -0
  83. package/src/infra/node-version.ts +2 -5
  84. package/src/monitor.ts +3 -1
  85. package/src/onboarding/provider-status.ts +10 -38
  86. package/src/pi/session-writer-lock.ts +156 -0
  87. package/src/pi/session.ts +2 -1
  88. package/src/prompts/policy-cards.ts +12 -4
  89. package/src/prompts/workflow-prompts.ts +28 -6
  90. package/src/providers/external-tool-command.ts +164 -0
  91. package/src/providers/reddit-cli.ts +10 -41
  92. package/src/providers/twitter-cli.ts +10 -41
  93. package/src/routing/classify-intent.ts +22 -17
  94. package/src/routing/entity-extractor.ts +1 -1
  95. package/src/routing/planning.ts +15 -10
  96. package/src/routing/router-llm-client.ts +2 -1
  97. package/src/routing/router.ts +57 -6
  98. package/src/workflows/compare-assets.ts +4 -2
  99. package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +0 -1
  100. package/gui/web/dist/assets/index-C6W_2eAn.js +0 -69
  101. 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(toolName: string, args: Record<string, unknown>): Promise<InvokeToolResult>;
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
- const result = await invokeTool(getSessionManager(), tool, args, "ui", { askUserHandler });
54
- if (!result.isError && marketStateToolMapping(toolName) != null) {
55
- onMarketStateChanged?.();
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({ type: "message.created", messageId: currentAssistantMessageId, role: "assistant" });
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
  });
@@ -1,7 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { createReadStream, existsSync } from "node:fs";
3
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
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 { probeProviderStatus } from "../../src/onboarding/provider-status.js";
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 { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
33
- import {
34
- buildMarketStateQuoteSnapshot,
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, promptAndSettle } from "./session-actions.js";
43
- import { waitForNewEntryId } from "./session-entry-wait.js";
32
+ import { createSessionActionsController } from "./session-actions.js";
44
33
  import { createGracefulShutdown } from "./shutdown.js";
45
- import { acquireWriterLock, refreshWriterLock, releaseWriterLock } from "./writer-lock.js";
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 lockResult = await acquireWriterLock(sessionDir, "gui");
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(sessionDir), 5000);
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 server = createServer((req, res) => {
164
- void handleHttpRequest(req, res);
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
- async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
168
- const url = new URL(req.url ?? "/", `http://${host}:${port}`);
169
- if (url.pathname === "/health") {
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(sessionDir);
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
- }