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
@@ -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,
@@ -44,37 +44,56 @@ export interface Usage {
44
44
  cost?: unknown;
45
45
  }
46
46
 
47
+ interface ChatEventBase {
48
+ sessionId: string;
49
+ seq: number;
50
+ }
51
+
52
+ interface RunScopedChatEventBase extends ChatEventBase {
53
+ runId: string;
54
+ }
55
+
47
56
  export type ChatEvent =
48
- | { type: "run.started"; runId: string; sessionId: string; seq: number }
49
- | { type: "thinking.delta"; runId: string; text: string; seq: number }
50
- | { type: "thinking.completed"; runId: string; text?: string; seq: number }
51
- | { type: "message.created"; messageId: string; role: ChatRole; seq: number }
52
- | { type: "message.delta"; messageId: string; text: string; seq: number }
53
- | { type: "message.completed"; messageId: string; content: MessageContent[]; seq: number }
54
- | {
57
+ | ({ type: "run.started" } & RunScopedChatEventBase)
58
+ | ({ type: "thinking.delta"; text: string } & RunScopedChatEventBase)
59
+ | ({ type: "thinking.completed"; text?: string } & RunScopedChatEventBase)
60
+ | ({ type: "message.created"; messageId: string; role: ChatRole; runId?: string } & ChatEventBase)
61
+ | ({ type: "message.delta"; messageId: string; text: string; runId?: string } & ChatEventBase)
62
+ | ({
63
+ type: "message.completed";
64
+ messageId: string;
65
+ content: MessageContent[];
66
+ runId?: string;
67
+ } & ChatEventBase)
68
+ | ({
55
69
  type: "custom.message";
56
70
  messageId: string;
57
71
  customType: string;
58
72
  content: MessageContent[];
59
- seq: number;
60
- }
61
- | {
73
+ } & ChatEventBase)
74
+ | ({
62
75
  type: "tool.started";
63
76
  toolCallId: string;
64
77
  messageId: string;
65
78
  name: string;
66
79
  input: unknown;
67
- seq: number;
68
- }
69
- | { type: "tool.delta"; toolCallId: string; chunk: unknown; seq: number }
70
- | { type: "tool.completed"; toolCallId: string; output: ToolOutput; seq: number }
71
- | { type: "tool.failed"; toolCallId: string; error: ToolError; seq: number }
72
- | { type: "run.completed"; runId: string; usage?: Usage; seq: number }
73
- | { type: "run.failed"; runId: string; error: RunError; seq: number }
74
- | { type: "session.updated"; sessionId: string; title?: string; updatedAt: string; seq: number };
80
+ runId?: string;
81
+ } & ChatEventBase)
82
+ | ({ type: "tool.delta"; toolCallId: string; chunk: unknown; runId?: string } & ChatEventBase)
83
+ | ({
84
+ type: "tool.completed";
85
+ toolCallId: string;
86
+ output: ToolOutput;
87
+ runId?: string;
88
+ } & ChatEventBase)
89
+ | ({ type: "tool.failed"; toolCallId: string; error: ToolError; runId?: string } & ChatEventBase)
90
+ | ({ type: "run.completed"; usage?: Usage } & RunScopedChatEventBase)
91
+ | ({ type: "run.failed"; error: RunError } & RunScopedChatEventBase)
92
+ | ({ type: "session.updated"; title?: string; updatedAt: string } & ChatEventBase);
75
93
 
76
94
  export interface RenderMessage {
77
95
  id: string;
96
+ sessionId?: string;
78
97
  role: ChatRole;
79
98
  status: "streaming" | "completed";
80
99
  content: MessageContent[];
@@ -84,6 +103,7 @@ export interface RenderMessage {
84
103
 
85
104
  export interface RenderToolCall {
86
105
  id: string;
106
+ sessionId?: string;
87
107
  messageId: string;
88
108
  name: string;
89
109
  input: unknown;
@@ -95,6 +115,7 @@ export interface RenderToolCall {
95
115
 
96
116
  export interface RenderThinking {
97
117
  runId: string;
118
+ sessionId?: string;
98
119
  status: "streaming" | "completed";
99
120
  text: string;
100
121
  }
@@ -109,7 +130,8 @@ export interface ChatRunState {
109
130
 
110
131
  export interface ChatRenderState {
111
132
  lastSeq: number;
112
- seenSeq: Set<number>;
133
+ seenSeq: Set<string>;
134
+ lastSeqBySession: Map<string, number>;
113
135
  messages: RenderMessage[];
114
136
  messageById: Map<string, RenderMessage>;
115
137
  tools: Map<string, RenderToolCall>;
@@ -123,6 +145,7 @@ export function createChatRenderState(): ChatRenderState {
123
145
  return {
124
146
  lastSeq: 0,
125
147
  seenSeq: new Set(),
148
+ lastSeqBySession: new Map(),
126
149
  messages: [],
127
150
  messageById: new Map(),
128
151
  tools: new Map(),
@@ -8,23 +8,26 @@ import {
8
8
 
9
9
  export function reduceChatEvents(events: ChatEvent[]): ChatRenderState {
10
10
  return [...events]
11
- .sort((a, b) => a.seq - b.seq)
11
+ .sort((a, b) => (a.sessionId === b.sessionId ? a.seq - b.seq : 0))
12
12
  .reduce((state, event) => applyChatEvent(state, event), createChatRenderState());
13
13
  }
14
14
 
15
15
  export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRenderState {
16
- if (state.seenSeq.has(event.seq)) return state;
16
+ const eventKey = `${event.sessionId}:${event.seq}`;
17
+ if (state.seenSeq.has(eventKey)) return state;
17
18
 
18
19
  const next = cloneState(state);
19
- if (event.seq > next.lastSeq + 1 && next.lastSeq !== 0) {
20
- next.gaps.push({ expected: next.lastSeq + 1, received: event.seq });
20
+ const sessionLastSeq = next.lastSeqBySession.get(event.sessionId) ?? 0;
21
+ if (event.seq > sessionLastSeq + 1 && sessionLastSeq !== 0) {
22
+ next.gaps.push({ expected: sessionLastSeq + 1, received: event.seq });
21
23
  }
22
- next.seenSeq.add(event.seq);
24
+ next.seenSeq.add(eventKey);
25
+ next.lastSeqBySession.set(event.sessionId, Math.max(sessionLastSeq, event.seq));
23
26
  next.lastSeq = Math.max(next.lastSeq, event.seq);
24
27
 
25
28
  switch (event.type) {
26
29
  case "run.started":
27
- next.runs.set(event.runId, {
30
+ next.runs.set(scopedId(event.sessionId, event.runId), {
28
31
  id: event.runId,
29
32
  sessionId: event.sessionId,
30
33
  status: "running",
@@ -33,25 +36,25 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
33
36
  break;
34
37
 
35
38
  case "thinking.delta": {
36
- const thinking = ensureThinking(next, event.runId);
39
+ const thinking = ensureThinking(next, event.sessionId, event.runId);
37
40
  thinking.status = "streaming";
38
41
  thinking.text += event.text;
39
42
  break;
40
43
  }
41
44
 
42
45
  case "thinking.completed": {
43
- const thinking = ensureThinking(next, event.runId);
46
+ const thinking = ensureThinking(next, event.sessionId, event.runId);
44
47
  thinking.status = "completed";
45
48
  if (event.text !== undefined) thinking.text = event.text;
46
49
  break;
47
50
  }
48
51
 
49
52
  case "message.created":
50
- ensureMessage(next, event.messageId, event.role);
53
+ ensureMessage(next, event.sessionId, event.messageId, event.role);
51
54
  break;
52
55
 
53
56
  case "message.delta": {
54
- const message = ensureMessage(next, event.messageId, "assistant");
57
+ const message = ensureMessage(next, event.sessionId, event.messageId, "assistant");
55
58
  message.status = "streaming";
56
59
  message.text += event.text;
57
60
  message.content = [{ type: "text", text: message.text }];
@@ -59,7 +62,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
59
62
  }
60
63
 
61
64
  case "message.completed": {
62
- const message = ensureMessage(next, event.messageId, "assistant");
65
+ const message = ensureMessage(next, event.sessionId, event.messageId, "assistant");
63
66
  message.status = "completed";
64
67
  message.content = event.content;
65
68
  message.text = contentText(event.content);
@@ -67,7 +70,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
67
70
  }
68
71
 
69
72
  case "custom.message": {
70
- const message = ensureMessage(next, event.messageId, "assistant");
73
+ const message = ensureMessage(next, event.sessionId, event.messageId, "assistant");
71
74
  message.status = "completed";
72
75
  message.content = event.content;
73
76
  message.text = contentText(event.content);
@@ -76,9 +79,10 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
76
79
  }
77
80
 
78
81
  case "tool.started":
79
- ensureMessage(next, event.messageId, "assistant");
80
- next.tools.set(event.toolCallId, {
82
+ ensureMessage(next, event.sessionId, event.messageId, "assistant");
83
+ next.tools.set(scopedId(event.sessionId, event.toolCallId), {
81
84
  id: event.toolCallId,
85
+ sessionId: event.sessionId,
82
86
  messageId: event.messageId,
83
87
  name: event.name,
84
88
  input: event.input,
@@ -88,13 +92,13 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
88
92
  break;
89
93
 
90
94
  case "tool.delta": {
91
- const tool = next.tools.get(event.toolCallId);
95
+ const tool = next.tools.get(scopedId(event.sessionId, event.toolCallId));
92
96
  if (tool) tool.chunks.push(event.chunk);
93
97
  break;
94
98
  }
95
99
 
96
100
  case "tool.completed": {
97
- const tool = next.tools.get(event.toolCallId);
101
+ const tool = next.tools.get(scopedId(event.sessionId, event.toolCallId));
98
102
  if (tool) {
99
103
  tool.status = event.output.isError ? "failed" : "completed";
100
104
  tool.output = event.output;
@@ -103,7 +107,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
103
107
  }
104
108
 
105
109
  case "tool.failed": {
106
- const tool = next.tools.get(event.toolCallId);
110
+ const tool = next.tools.get(scopedId(event.sessionId, event.toolCallId));
107
111
  if (tool) {
108
112
  tool.status = "failed";
109
113
  tool.error = event.error;
@@ -112,7 +116,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
112
116
  }
113
117
 
114
118
  case "run.completed": {
115
- const run = next.runs.get(event.runId);
119
+ const run = next.runs.get(scopedId(event.sessionId, event.runId));
116
120
  if (run) {
117
121
  run.status = "completed";
118
122
  run.usage = event.usage;
@@ -121,7 +125,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
121
125
  }
122
126
 
123
127
  case "run.failed": {
124
- const run = next.runs.get(event.runId);
128
+ const run = next.runs.get(scopedId(event.sessionId, event.runId));
125
129
  if (run) {
126
130
  run.status = "failed";
127
131
  run.error = event.error;
@@ -143,20 +147,23 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
143
147
 
144
148
  function ensureMessage(
145
149
  state: ChatRenderState,
150
+ sessionId: string,
146
151
  id: string,
147
152
  role: RenderMessage["role"],
148
153
  ): RenderMessage {
149
- const existing = state.messageById.get(id);
154
+ const key = scopedId(sessionId, id);
155
+ const existing = state.messageById.get(key);
150
156
  if (existing) return existing;
151
157
 
152
158
  const message: RenderMessage = {
153
159
  id,
160
+ sessionId,
154
161
  role,
155
162
  status: "streaming",
156
163
  content: [],
157
164
  text: "",
158
165
  };
159
- state.messageById.set(id, message);
166
+ state.messageById.set(key, message);
160
167
  state.messages.push(message);
161
168
  return message;
162
169
  }
@@ -166,10 +173,13 @@ function cloneState(state: ChatRenderState): ChatRenderState {
166
173
  ...message,
167
174
  content: [...message.content],
168
175
  }));
169
- const messageById = new Map(messages.map((message) => [message.id, message]));
176
+ const messageById = new Map(
177
+ messages.map((message) => [scopedId(message.sessionId ?? "", message.id), message]),
178
+ );
170
179
  return {
171
180
  ...state,
172
181
  seenSeq: new Set(state.seenSeq),
182
+ lastSeqBySession: new Map(state.lastSeqBySession),
173
183
  messages,
174
184
  messageById,
175
185
  tools: new Map(
@@ -188,14 +198,19 @@ function cloneState(state: ChatRenderState): ChatRenderState {
188
198
  };
189
199
  }
190
200
 
191
- function ensureThinking(state: ChatRenderState, runId: string) {
192
- const existing = state.thinking.get(runId);
201
+ function ensureThinking(state: ChatRenderState, sessionId: string, runId: string) {
202
+ const key = scopedId(sessionId, runId);
203
+ const existing = state.thinking.get(key);
193
204
  if (existing) return existing;
194
- const thinking = { runId, status: "streaming" as const, text: "" };
195
- state.thinking.set(runId, thinking);
205
+ const thinking = { runId, sessionId, status: "streaming" as const, text: "" };
206
+ state.thinking.set(key, thinking);
196
207
  return thinking;
197
208
  }
198
209
 
210
+ function scopedId(sessionId: string, id: string): string {
211
+ return `${sessionId ?? ""}::${id}`;
212
+ }
213
+
199
214
  function contentText(content: MessageContent[]): string {
200
215
  return content
201
216
  .filter((part): part is Extract<MessageContent, { type: "text" }> => part.type === "text")