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.
Files changed (46) hide show
  1. package/dist/cli-main.js +48 -24
  2. package/dist/cli-main.js.map +1 -1
  3. package/dist/pi/session-writer-lock.d.ts +30 -0
  4. package/dist/pi/session-writer-lock.js +111 -0
  5. package/dist/pi/session-writer-lock.js.map +1 -0
  6. package/dist/prompts/workflow-prompts.js +15 -0
  7. package/dist/prompts/workflow-prompts.js.map +1 -1
  8. package/dist/routing/classify-intent.js +8 -9
  9. package/dist/routing/classify-intent.js.map +1 -1
  10. package/dist/routing/entity-extractor.js +1 -1
  11. package/dist/routing/entity-extractor.js.map +1 -1
  12. package/dist/routing/router-llm-client.d.ts +1 -1
  13. package/dist/routing/router-llm-client.js +1 -1
  14. package/dist/routing/router-llm-client.js.map +1 -1
  15. package/dist/routing/router.js +37 -0
  16. package/dist/routing/router.js.map +1 -1
  17. package/dist/workflows/compare-assets.js +4 -1
  18. package/dist/workflows/compare-assets.js.map +1 -1
  19. package/gui/server/ask-user-bridge.ts +26 -19
  20. package/gui/server/chat-event-adapter.ts +49 -3
  21. package/gui/server/http-routes.ts +173 -15
  22. package/gui/server/invoke-tool.ts +102 -7
  23. package/gui/server/live-chat-event-adapter.ts +16 -4
  24. package/gui/server/server.ts +42 -5
  25. package/gui/server/session-actions.ts +7 -1
  26. package/gui/server/session-entry-wait.ts +79 -0
  27. package/gui/server/writer-lock.ts +1 -122
  28. package/gui/server/ws-hub.ts +25 -0
  29. package/gui/shared/chat-events.ts +42 -19
  30. package/gui/shared/event-reducer.ts +41 -26
  31. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +1 -0
  32. package/gui/web/dist/assets/index-B7QAjY5g.js +65 -0
  33. package/gui/web/dist/assets/index-D5dbWPfM.css +2 -0
  34. package/gui/web/dist/index.html +2 -2
  35. package/package.json +16 -16
  36. package/src/cli-main.ts +64 -27
  37. package/src/pi/session-writer-lock.ts +156 -0
  38. package/src/prompts/workflow-prompts.ts +15 -0
  39. package/src/routing/classify-intent.ts +11 -11
  40. package/src/routing/entity-extractor.ts +1 -1
  41. package/src/routing/router-llm-client.ts +2 -1
  42. package/src/routing/router.ts +46 -0
  43. package/src/workflows/compare-assets.ts +4 -2
  44. package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +0 -1
  45. package/gui/web/dist/assets/index-Dm4Aom2_.js +0 -69
  46. 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(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
  });
@@ -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 { acquireWriterLock, refreshWriterLock, releaseWriterLock } from "./writer-lock.js";
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 lockResult = await acquireWriterLock(sessionDir, "gui");
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(sessionDir), 5000);
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(sessionDir);
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 { waitForNewEntryId, waitForSessionTurnSettlement } from "./session-entry-wait.js";
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
- import { mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
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";
@@ -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,