opencandle 0.8.0 → 0.10.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/dist/cli-main.js +48 -24
- package/dist/cli-main.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/prompts/workflow-prompts.js +15 -0
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/routing/classify-intent.js +8 -9
- 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/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 +37 -0
- 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 +173 -15
- package/gui/server/invoke-tool.ts +102 -7
- package/gui/server/live-chat-event-adapter.ts +16 -4
- package/gui/server/server.ts +42 -5
- 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-CYptsda-.js +1 -0
- package/gui/web/dist/assets/index-B7QAjY5g.js +65 -0
- package/gui/web/dist/assets/index-D5dbWPfM.css +2 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +16 -16
- package/src/cli-main.ts +64 -27
- package/src/pi/session-writer-lock.ts +156 -0
- package/src/prompts/workflow-prompts.ts +15 -0
- package/src/routing/classify-intent.ts +11 -11
- package/src/routing/entity-extractor.ts +1 -1
- package/src/routing/router-llm-client.ts +2 -1
- package/src/routing/router.ts +46 -0
- package/src/workflows/compare-assets.ts +4 -2
- package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +0 -1
- package/gui/web/dist/assets/index-Dm4Aom2_.js +0 -69
- package/gui/web/dist/assets/index-UzZUg3dx.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
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
createBackgroundQuotePoller,
|
|
24
24
|
} from "./background-quotes.js";
|
|
25
25
|
import { createInitialGuiSessionManager } from "./gui-session-manager.js";
|
|
26
|
-
import { createHttpRequestHandler } from "./http-routes.js";
|
|
26
|
+
import { createHttpRequestHandler, resolveSessionManagerById } from "./http-routes.js";
|
|
27
27
|
import { createToolInvokeController } from "./invoke-tool.js";
|
|
28
28
|
import { buildMarketStateQuoteSnapshot } from "./market-state-api.js";
|
|
29
29
|
import { createModelSetupController } from "./model-setup.js";
|
|
@@ -31,7 +31,12 @@ import { isTrustedPrivateApiRequest } from "./private-api-access.js";
|
|
|
31
31
|
import { QuoteSnapshotStore } from "./quote-snapshot-store.js";
|
|
32
32
|
import { createSessionActionsController } from "./session-actions.js";
|
|
33
33
|
import { createGracefulShutdown } from "./shutdown.js";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
acquireWriterLock,
|
|
36
|
+
refreshWriterLock,
|
|
37
|
+
releaseWriterLock,
|
|
38
|
+
writerLockScopeForSession,
|
|
39
|
+
} from "./writer-lock.js";
|
|
35
40
|
import { createWsHub, type WsHub } from "./ws-hub.js";
|
|
36
41
|
|
|
37
42
|
assertSupportedNodeVersion();
|
|
@@ -54,7 +59,9 @@ const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
|
54
59
|
const initialSessionManager = createInitialGuiSessionManager(cwd);
|
|
55
60
|
let sessionManager = initialSessionManager;
|
|
56
61
|
const sessionDir = sessionManager.getSessionDir();
|
|
57
|
-
const
|
|
62
|
+
const initialWriterLockScope = writerLockScopeForSession(sessionManager);
|
|
63
|
+
const lockResult = await acquireWriterLock(initialWriterLockScope, "gui");
|
|
64
|
+
let activeWriterLockScope = initialWriterLockScope;
|
|
58
65
|
let wsHub: WsHub;
|
|
59
66
|
let quotePoller: BackgroundQuotePoller;
|
|
60
67
|
const askUserBridge = createAskUserBridge({
|
|
@@ -84,7 +91,7 @@ const runtime = await createAgentSessionRuntime(
|
|
|
84
91
|
{ cwd, agentDir, sessionManager },
|
|
85
92
|
);
|
|
86
93
|
let session = runtime.session;
|
|
87
|
-
const heartbeat = setInterval(() => refreshWriterLock(
|
|
94
|
+
const heartbeat = setInterval(() => refreshWriterLock(activeWriterLockScope), 5000);
|
|
88
95
|
const backgroundQuoteRefreshes = new BackgroundQuoteRefreshes();
|
|
89
96
|
const quoteSnapshotStore = new QuoteSnapshotStore(() => buildMarketStateQuoteSnapshot());
|
|
90
97
|
quotePoller = createBackgroundQuotePoller({
|
|
@@ -108,8 +115,17 @@ const toolInvokeController = createToolInvokeController({
|
|
|
108
115
|
role: lockResult.role,
|
|
109
116
|
getSessionManager: () => sessionManager,
|
|
110
117
|
broadcastState: () => wsHub.broadcastState(),
|
|
118
|
+
broadcastSessionSnapshot: (targetSessionManager) =>
|
|
119
|
+
wsHub.broadcastSessionSnapshot(targetSessionManager),
|
|
120
|
+
broadcastSessions: () => wsHub.broadcastSessions(),
|
|
111
121
|
onMarketStateChanged: () => quoteSnapshotStore.invalidate(),
|
|
112
122
|
askUserHandler: askUserBridge.ask,
|
|
123
|
+
askUserHandlerForSessionId: (sessionId) => askUserBridge.askForSession(sessionId),
|
|
124
|
+
resolveSessionManager: (sessionId) =>
|
|
125
|
+
resolveSessionManagerById(
|
|
126
|
+
{ cwd, sessionDir, getSessionManager: () => sessionManager },
|
|
127
|
+
sessionId,
|
|
128
|
+
),
|
|
113
129
|
});
|
|
114
130
|
const sessionActionsController = createSessionActionsController({
|
|
115
131
|
role: lockResult.role,
|
|
@@ -145,6 +161,17 @@ wsHub = createWsHub({
|
|
|
145
161
|
|
|
146
162
|
let unsubscribeSession = wsHub.subscribeToSessionEvents();
|
|
147
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
|
+
}
|
|
148
175
|
unsubscribeSession();
|
|
149
176
|
session = nextSession;
|
|
150
177
|
sessionManager = nextSession.sessionManager;
|
|
@@ -163,6 +190,16 @@ const httpRequestHandler = createHttpRequestHandler({
|
|
|
163
190
|
allowRemotePrivateApi,
|
|
164
191
|
getSession: () => session,
|
|
165
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
|
+
}),
|
|
166
203
|
wsHub,
|
|
167
204
|
modelSetupController,
|
|
168
205
|
sessionActionsController,
|
|
@@ -197,7 +234,7 @@ const shutdown = createGracefulShutdown({
|
|
|
197
234
|
localAutomationHeartbeat.stop();
|
|
198
235
|
wsHub.closeClients();
|
|
199
236
|
unsubscribeSession();
|
|
200
|
-
releaseWriterLock(
|
|
237
|
+
releaseWriterLock(activeWriterLockScope);
|
|
201
238
|
await runtime.dispose();
|
|
202
239
|
},
|
|
203
240
|
exit: (code) => process.exit(code),
|
|
@@ -2,7 +2,11 @@ import { unlink } from "node:fs/promises";
|
|
|
2
2
|
import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { ModelSetupState } from "./model-setup.js";
|
|
4
4
|
import { type PromptObservation, selectReplayPrompt } from "./prompt-observation.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
waitForNewEntryId,
|
|
7
|
+
waitForResolvedToolCalls,
|
|
8
|
+
waitForSessionTurnSettlement,
|
|
9
|
+
} from "./session-entry-wait.js";
|
|
6
10
|
|
|
7
11
|
interface AskUserBridge {
|
|
8
12
|
answer(id: string, answer: string): boolean;
|
|
@@ -164,6 +168,7 @@ export async function promptAndSettle(
|
|
|
164
168
|
() => runSession.sessionManager.getEntries().map((entry) => entry.id),
|
|
165
169
|
beforeIds,
|
|
166
170
|
);
|
|
171
|
+
await waitForResolvedToolCalls(() => runSession.sessionManager.getEntries());
|
|
167
172
|
await replayObservedWorkflowPromptIfNeeded(runSession, prompt, observation);
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -184,6 +189,7 @@ export async function replayObservedWorkflowPromptIfNeeded(
|
|
|
184
189
|
isStreaming: runSession.isStreaming,
|
|
185
190
|
pendingMessageCount: runSession.pendingMessageCount,
|
|
186
191
|
}));
|
|
192
|
+
await waitForResolvedToolCalls(() => runSession.sessionManager.getEntries());
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
export async function renameSessionFile(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
1
3
|
export interface WaitForEntryCountOptions {
|
|
2
4
|
timeoutMs?: number;
|
|
3
5
|
intervalMs?: number;
|
|
@@ -12,6 +14,11 @@ export interface SessionRunStatus {
|
|
|
12
14
|
pendingMessageCount: number;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
export interface UnresolvedToolCall {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export async function waitForEntryCount(
|
|
16
23
|
getCount: () => number,
|
|
17
24
|
previousCount: number,
|
|
@@ -76,6 +83,78 @@ export async function waitForSessionTurnSettlement(
|
|
|
76
83
|
throw new Error("Timed out waiting for the session turn to settle");
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
export function findUnresolvedToolCalls(entries: SessionEntry[]): UnresolvedToolCall[] {
|
|
87
|
+
const latestAssistantIndex = latestAssistantToolUseIndex(entries);
|
|
88
|
+
if (latestAssistantIndex === -1) return [];
|
|
89
|
+
|
|
90
|
+
const assistant = asMessage(entries[latestAssistantIndex]);
|
|
91
|
+
const calls = toolCallsFromContent(assistant?.content);
|
|
92
|
+
if (calls.length === 0) return [];
|
|
93
|
+
|
|
94
|
+
const resolved = new Set<string>();
|
|
95
|
+
for (const entry of entries.slice(latestAssistantIndex + 1)) {
|
|
96
|
+
const message = asMessage(entry);
|
|
97
|
+
if (message?.role !== "toolResult") continue;
|
|
98
|
+
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
|
99
|
+
if (toolCallId) resolved.add(toolCallId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return calls.filter((call) => !resolved.has(call.id));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function waitForResolvedToolCalls(
|
|
106
|
+
getEntries: () => SessionEntry[],
|
|
107
|
+
options: WaitForEntryCountOptions = {},
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
110
|
+
const intervalMs = options.intervalMs ?? 25;
|
|
111
|
+
const deadline = Date.now() + timeoutMs;
|
|
112
|
+
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
if (findUnresolvedToolCalls(getEntries()).length === 0) return;
|
|
115
|
+
await delay(intervalMs);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const unresolved = findUnresolvedToolCalls(getEntries());
|
|
119
|
+
if (unresolved.length > 0) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Timed out waiting for tool results: ${unresolved.map((call) => call.name).join(", ")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
79
126
|
function delay(ms: number): Promise<void> {
|
|
80
127
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
81
128
|
}
|
|
129
|
+
|
|
130
|
+
function latestAssistantToolUseIndex(entries: SessionEntry[]): number {
|
|
131
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
132
|
+
const message = asMessage(entries[index]);
|
|
133
|
+
if (message?.role === "assistant") {
|
|
134
|
+
if (toolCallsFromContent(message.content).length > 0) return index;
|
|
135
|
+
if (message.stopReason === "stop") return -1;
|
|
136
|
+
}
|
|
137
|
+
if (message?.role === "user") return -1;
|
|
138
|
+
}
|
|
139
|
+
return -1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function asMessage(entry: SessionEntry | undefined): Record<string, unknown> | undefined {
|
|
143
|
+
if (entry?.type !== "message") return undefined;
|
|
144
|
+
const message = (entry as { message?: unknown }).message;
|
|
145
|
+
return typeof message === "object" && message !== null && !Array.isArray(message)
|
|
146
|
+
? (message as Record<string, unknown>)
|
|
147
|
+
: undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toolCallsFromContent(content: unknown): UnresolvedToolCall[] {
|
|
151
|
+
if (!Array.isArray(content)) return [];
|
|
152
|
+
return content.flatMap((part): UnresolvedToolCall[] => {
|
|
153
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) return [];
|
|
154
|
+
const record = part as Record<string, unknown>;
|
|
155
|
+
if (record.type !== "toolCall") return [];
|
|
156
|
+
const id = typeof record.id === "string" ? record.id : "";
|
|
157
|
+
const name = typeof record.name === "string" ? record.name : "tool";
|
|
158
|
+
return id ? [{ id, name }] : [];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -1,122 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
export type ProcessKind = "tui" | "gui";
|
|
5
|
-
|
|
6
|
-
export interface WriterLock {
|
|
7
|
-
pid: number;
|
|
8
|
-
processKind: ProcessKind;
|
|
9
|
-
acquiredAt: string;
|
|
10
|
-
lastHeartbeat: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface AcquireOptions {
|
|
14
|
-
pid?: number;
|
|
15
|
-
staleGraceMs?: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type AcquireResult =
|
|
19
|
-
| { role: "writer"; lock: WriterLock }
|
|
20
|
-
| { role: "follower"; lock: WriterLock };
|
|
21
|
-
|
|
22
|
-
const DEFAULT_STALE_GRACE_MS = 15_000;
|
|
23
|
-
|
|
24
|
-
export async function acquireWriterLock(
|
|
25
|
-
sessionDir: string,
|
|
26
|
-
processKind: ProcessKind,
|
|
27
|
-
options: AcquireOptions = {},
|
|
28
|
-
): Promise<AcquireResult> {
|
|
29
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
30
|
-
const pid = options.pid ?? process.pid;
|
|
31
|
-
const staleGraceMs = options.staleGraceMs ?? DEFAULT_STALE_GRACE_MS;
|
|
32
|
-
|
|
33
|
-
const created = tryCreate(sessionDir, processKind, pid);
|
|
34
|
-
if (created) return { role: "writer", lock: created };
|
|
35
|
-
|
|
36
|
-
const existing = readWriterLock(sessionDir);
|
|
37
|
-
if (existing && isLockCurrent(existing, staleGraceMs)) {
|
|
38
|
-
return { role: "follower", lock: existing };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
await sleep(staleGraceMs);
|
|
42
|
-
const afterGrace = readWriterLock(sessionDir);
|
|
43
|
-
if (afterGrace && isLockCurrent(afterGrace, staleGraceMs)) {
|
|
44
|
-
return { role: "follower", lock: afterGrace };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
unlinkSync(lockPath(sessionDir));
|
|
49
|
-
} catch {
|
|
50
|
-
// Missing or concurrently removed is fine; the next create decides ownership.
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const recovered = tryCreate(sessionDir, processKind, pid);
|
|
54
|
-
if (recovered) return { role: "writer", lock: recovered };
|
|
55
|
-
|
|
56
|
-
const current = readWriterLock(sessionDir) ?? afterGrace ?? existing;
|
|
57
|
-
if (!current) throw new Error("Unable to determine active writer lock");
|
|
58
|
-
return { role: "follower", lock: current };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function readWriterLock(sessionDir: string): WriterLock | null {
|
|
62
|
-
try {
|
|
63
|
-
return JSON.parse(readFileSync(lockPath(sessionDir), "utf8")) as WriterLock;
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function refreshWriterLock(sessionDir: string, pid = process.pid): void {
|
|
70
|
-
const lock = readWriterLock(sessionDir);
|
|
71
|
-
if (!lock || lock.pid !== pid) return;
|
|
72
|
-
writeFileSync(
|
|
73
|
-
lockPath(sessionDir),
|
|
74
|
-
JSON.stringify({ ...lock, lastHeartbeat: new Date().toISOString() }, null, 2),
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function releaseWriterLock(sessionDir: string, pid = process.pid): void {
|
|
79
|
-
const lock = readWriterLock(sessionDir);
|
|
80
|
-
if (!lock || lock.pid !== pid) return;
|
|
81
|
-
try {
|
|
82
|
-
unlinkSync(lockPath(sessionDir));
|
|
83
|
-
} catch {
|
|
84
|
-
// Best effort shutdown cleanup.
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function tryCreate(sessionDir: string, processKind: ProcessKind, pid: number): WriterLock | null {
|
|
89
|
-
const now = new Date().toISOString();
|
|
90
|
-
const lock: WriterLock = { pid, processKind, acquiredAt: now, lastHeartbeat: now };
|
|
91
|
-
try {
|
|
92
|
-
const fd = openSync(lockPath(sessionDir), "wx");
|
|
93
|
-
writeFileSync(fd, JSON.stringify(lock, null, 2));
|
|
94
|
-
return lock;
|
|
95
|
-
} catch {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function isPidAlive(pid: number): boolean {
|
|
101
|
-
try {
|
|
102
|
-
process.kill(pid, 0);
|
|
103
|
-
return true;
|
|
104
|
-
} catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function isLockCurrent(lock: WriterLock, staleGraceMs: number): boolean {
|
|
110
|
-
const heartbeat = Date.parse(lock.lastHeartbeat);
|
|
111
|
-
return (
|
|
112
|
-
isPidAlive(lock.pid) && Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function lockPath(sessionDir: string): string {
|
|
117
|
-
return join(sessionDir, "writer.lock");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function sleep(ms: number): Promise<void> {
|
|
121
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
-
}
|
|
1
|
+
export * from "../../src/pi/session-writer-lock.js";
|
package/gui/server/ws-hub.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface WsHub {
|
|
|
35
35
|
broadcast(message: unknown): void;
|
|
36
36
|
broadcastModelSetup(): void;
|
|
37
37
|
broadcastState(): void;
|
|
38
|
+
broadcastSessionSnapshot(sessionManager: SessionManager): void;
|
|
38
39
|
broadcastSessions(): void;
|
|
39
40
|
buildStateSnapshot(): {
|
|
40
41
|
sessionId: string;
|
|
@@ -251,6 +252,13 @@ export function createWsHub({
|
|
|
251
252
|
});
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
function broadcastSessionSnapshot(sessionManager: SessionManager): void {
|
|
256
|
+
broadcast({
|
|
257
|
+
type: "session.snapshot",
|
|
258
|
+
...buildSnapshotForSession(sessionManager),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
254
262
|
function buildStateSnapshot() {
|
|
255
263
|
const sessionManager = getSessionManager();
|
|
256
264
|
const sessionId = sessionManager.getSessionId();
|
|
@@ -263,11 +271,27 @@ export function createWsHub({
|
|
|
263
271
|
};
|
|
264
272
|
}
|
|
265
273
|
|
|
274
|
+
function buildSnapshotForSession(sessionManager: SessionManager) {
|
|
275
|
+
const sessionId = sessionManager.getSessionId();
|
|
276
|
+
const entries = sessionManager.getEntries();
|
|
277
|
+
return {
|
|
278
|
+
sessionId,
|
|
279
|
+
state: projectDashboard(backgroundQuoteRefreshes.withEntries(entries), sessionId),
|
|
280
|
+
entries,
|
|
281
|
+
events: sessionEntriesToChatEvents(entries, {
|
|
282
|
+
sessionId,
|
|
283
|
+
title: sessionManager.getSessionName(),
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
266
288
|
function currentChatEvents(entries = getSessionManager().getEntries()): ChatEvent[] {
|
|
267
289
|
const sessionManager = getSessionManager();
|
|
290
|
+
const session = getSession();
|
|
268
291
|
return sessionEntriesToChatEvents(entries, {
|
|
269
292
|
sessionId: sessionManager.getSessionId(),
|
|
270
293
|
title: sessionManager.getSessionName(),
|
|
294
|
+
markUnresolvedToolCalls: !(session.isStreaming || session.pendingMessageCount > 0),
|
|
271
295
|
});
|
|
272
296
|
}
|
|
273
297
|
|
|
@@ -297,6 +321,7 @@ export function createWsHub({
|
|
|
297
321
|
broadcast,
|
|
298
322
|
broadcastModelSetup,
|
|
299
323
|
broadcastState,
|
|
324
|
+
broadcastSessionSnapshot,
|
|
300
325
|
broadcastSessions,
|
|
301
326
|
buildStateSnapshot,
|
|
302
327
|
currentChatEvents,
|