@osolmaz/pi-workflows 0.16.0 → 0.16.1

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 (154) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +42 -16
  22. package/dist/client/view.js.map +1 -1
  23. package/dist/controllers/index.d.ts +1 -1
  24. package/dist/controllers/index.js.map +1 -1
  25. package/dist/controllers/sqlite.d.ts +12 -165
  26. package/dist/controllers/sqlite.js +124 -435
  27. package/dist/controllers/sqlite.js.map +1 -1
  28. package/dist/extension/index.d.ts +23 -2
  29. package/dist/extension/index.js +619 -535
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/recorder.d.ts +95 -0
  32. package/dist/extension/recorder.js +530 -0
  33. package/dist/extension/recorder.js.map +1 -0
  34. package/dist/extension/remote-recorder-store.d.ts +25 -0
  35. package/dist/extension/remote-recorder-store.js +81 -0
  36. package/dist/extension/remote-recorder-store.js.map +1 -0
  37. package/dist/extension/session-events.d.ts +134 -0
  38. package/dist/extension/session-events.js +60 -0
  39. package/dist/extension/session-events.js.map +1 -0
  40. package/dist/extension/session-view.d.ts +2 -0
  41. package/dist/extension/session-view.js +60 -2
  42. package/dist/extension/session-view.js.map +1 -1
  43. package/dist/extension/step-message.d.ts +6 -4
  44. package/dist/extension/step-message.js +12 -6
  45. package/dist/extension/step-message.js.map +1 -1
  46. package/dist/extension/widget.d.ts +2 -2
  47. package/dist/extension/widget.js +28 -18
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  50. package/dist/extension/workflow-message-coordinator.js +294 -0
  51. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  52. package/dist/host/channel-effects.d.ts +72 -0
  53. package/dist/host/channel-effects.js +271 -0
  54. package/dist/host/channel-effects.js.map +1 -0
  55. package/dist/host/channel-supervisor.d.ts +21 -0
  56. package/dist/host/channel-supervisor.js +54 -0
  57. package/dist/host/channel-supervisor.js.map +1 -0
  58. package/dist/host/runner.d.ts +43 -8
  59. package/dist/host/runner.js +1567 -331
  60. package/dist/host/runner.js.map +1 -1
  61. package/dist/host/state.d.ts +19 -16
  62. package/dist/host/state.js +251 -63
  63. package/dist/host/state.js.map +1 -1
  64. package/dist/host/view.d.ts +19 -9
  65. package/dist/host/view.js +222 -118
  66. package/dist/host/view.js.map +1 -1
  67. package/dist/state/prune.js +3 -25
  68. package/dist/state/prune.js.map +1 -1
  69. package/dist/state/schema.js +76 -97
  70. package/dist/state/schema.js.map +1 -1
  71. package/dist/state/workflow-messages.d.ts +96 -0
  72. package/dist/state/workflow-messages.js +335 -0
  73. package/dist/state/workflow-messages.js.map +1 -0
  74. package/dist/viewer/render.js +1 -1
  75. package/dist/viewer/render.js.map +1 -1
  76. package/dist/viewer/tui.d.ts +1 -1
  77. package/dist/viewer/tui.js +65 -8
  78. package/dist/viewer/tui.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +2 -0
  80. package/dist/workflows/human-decision.js +8 -2
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +1 -1
  83. package/dist/workflows/index.js.map +1 -1
  84. package/dist/workflows/settings.d.ts +1 -8
  85. package/dist/workflows/settings.js.map +1 -1
  86. package/dist/workflows/store.d.ts +5 -19
  87. package/dist/workflows/store.js +121 -354
  88. package/dist/workflows/store.js.map +1 -1
  89. package/dist/workflows/tool-input.d.ts +21 -0
  90. package/dist/workflows/tool-input.js +23 -1
  91. package/dist/workflows/tool-input.js.map +1 -1
  92. package/dist/workflows/types.d.ts +5 -5
  93. package/dist/workflows/workflow-message-content.d.ts +38 -0
  94. package/dist/workflows/workflow-message-content.js +157 -0
  95. package/dist/workflows/workflow-message-content.js.map +1 -0
  96. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  97. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  98. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  99. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  100. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  101. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  102. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  103. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  104. package/docs/DEFERRED_TURNS.md +66 -294
  105. package/docs/HUMAN_DECISIONS.md +29 -30
  106. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  107. package/docs/SQLITE_STATE.md +19 -15
  108. package/docs/WORKFLOW_HOST.md +93 -28
  109. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  110. package/docs/live-replay-protocol.md +8 -4
  111. package/docs/tui-viewer.md +3 -1
  112. package/docs/workflows.md +34 -27
  113. package/herdr-plugin.toml +1 -1
  114. package/package.json +1 -1
  115. package/plugins/herdr/viewer.mjs +14 -5
  116. package/src/channels/adapter-entry.ts +220 -0
  117. package/src/channels/config.ts +296 -0
  118. package/src/channels/protocol.ts +333 -0
  119. package/src/channels/telegram.ts +335 -0
  120. package/src/client/client.ts +6 -2
  121. package/src/client/index.ts +2 -1
  122. package/src/client/protocol.ts +11 -5
  123. package/src/client/view.ts +43 -18
  124. package/src/controllers/index.ts +0 -1
  125. package/src/controllers/sqlite.ts +164 -844
  126. package/src/extension/index.ts +693 -646
  127. package/src/extension/recorder.ts +687 -0
  128. package/src/extension/remote-recorder-store.ts +126 -0
  129. package/src/extension/session-events.ts +119 -0
  130. package/src/extension/session-view.ts +86 -3
  131. package/src/extension/step-message.ts +19 -9
  132. package/src/extension/widget.ts +37 -17
  133. package/src/extension/workflow-message-coordinator.ts +334 -0
  134. package/src/host/channel-effects.ts +465 -0
  135. package/src/host/channel-supervisor.ts +73 -0
  136. package/src/host/runner.ts +2120 -531
  137. package/src/host/state.ts +333 -94
  138. package/src/host/view.ts +249 -121
  139. package/src/state/prune.ts +3 -31
  140. package/src/state/schema.ts +76 -97
  141. package/src/state/workflow-messages.ts +548 -0
  142. package/src/viewer/render.ts +1 -5
  143. package/src/viewer/tui.ts +71 -6
  144. package/src/workflows/human-decision.ts +18 -2
  145. package/src/workflows/index.ts +0 -1
  146. package/src/workflows/settings.ts +1 -20
  147. package/src/workflows/store.ts +173 -489
  148. package/src/workflows/tool-input.ts +36 -1
  149. package/src/workflows/types.ts +5 -5
  150. package/src/workflows/workflow-message-content.ts +197 -0
  151. package/dist/extension/decision-channels.d.ts +0 -134
  152. package/dist/extension/decision-channels.js +0 -1307
  153. package/dist/extension/decision-channels.js.map +0 -1
  154. package/src/extension/decision-channels.ts +0 -1826
@@ -0,0 +1,687 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ SESSION_BINDING_SCHEMA,
5
+ SESSION_CAPTURE_SCHEMA,
6
+ SESSION_EVENT_MAX_BYTES,
7
+ SESSION_EVENT_SCHEMA,
8
+ } from "../workflows/store.js";
9
+ import type {
10
+ AgentStepContract,
11
+ ConversationRange,
12
+ WorkflowSessionBinding,
13
+ WorkflowSessionCapture,
14
+ WorkflowSessionCaptureFailure,
15
+ WorkflowSessionEventRecord,
16
+ WorkflowSessionEventType,
17
+ } from "../workflows/types.js";
18
+ import {
19
+ messageRole,
20
+ normalizeAssistantEvent,
21
+ toolCallIdFromAssistantEvent,
22
+ toolFinishedPayload,
23
+ toolStartedPayload,
24
+ turnFinishedPayload,
25
+ type MessageEndEventLike,
26
+ type MessageStartEventLike,
27
+ type MessageUpdateEventLike,
28
+ type ToolExecutionEndEventLike,
29
+ type ToolExecutionStartEventLike,
30
+ type ToolExecutionUpdateEventLike,
31
+ type TurnEndEventLike,
32
+ type TurnStartEventLike,
33
+ } from "./session-events.js";
34
+
35
+ const FLUSH_INTERVAL_MS = 25;
36
+ const FINISH_TURN_TIMEOUT_MS = 30_000;
37
+ const FLUSH_MAX_RECORDS = 256;
38
+ const FLUSH_MAX_BYTES = 256 * 1024;
39
+ const QUEUE_MAX_RECORDS = 8_192;
40
+ const QUEUE_MAX_BYTES = 16 * 1024 * 1024;
41
+
42
+ type AttemptOwner = { nodeId: string; attemptId: string };
43
+ type TurnOwner = AttemptOwner & { turnId: string; turnIndex: number };
44
+ type MessageOwner = TurnOwner & { messageId: string; role: string };
45
+ type QueuedEvent = { record: WorkflowSessionEventRecord; bytes: number };
46
+ type PendingMessageEnd = { queued: QueuedEvent; role: string; message: unknown };
47
+ type RecordedEntry = { id: string; entry: Record<string, unknown>; claimed: boolean };
48
+
49
+ function objectKey(value: unknown): object | null {
50
+ return typeof value === "object" && value !== null ? value : null;
51
+ }
52
+
53
+ function stableMessageKey(value: unknown): string | null {
54
+ if (typeof value !== "object" || value === null) {
55
+ return null;
56
+ }
57
+ const message = value as { id?: unknown; role?: unknown; timestamp?: unknown };
58
+ if (typeof message.id === "string" && message.id.length > 0) {
59
+ return `id:${message.id}`;
60
+ }
61
+ if (
62
+ (typeof message.timestamp === "number" || typeof message.timestamp === "string") &&
63
+ typeof message.role === "string"
64
+ ) {
65
+ return `timestamp:${message.role}:${message.timestamp}`;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function entryRole(entry: Record<string, unknown>): string {
71
+ const message = entry.message;
72
+ return messageRole(message);
73
+ }
74
+
75
+ function failureMessage(error: unknown): string {
76
+ return error instanceof Error ? error.message : String(error);
77
+ }
78
+
79
+ export type SessionRecordingStore = {
80
+ hasSessionBinding(runId: string): Promise<boolean>;
81
+ writeSessionBinding(
82
+ runId: string,
83
+ binding: WorkflowSessionBinding,
84
+ attemptId?: string,
85
+ ): Promise<void>;
86
+ writeSessionCapture(
87
+ runId: string,
88
+ capture: WorkflowSessionCapture,
89
+ attemptId?: string,
90
+ ): Promise<void>;
91
+ appendSessionEntry(
92
+ runId: string,
93
+ entry: Record<string, unknown>,
94
+ attemptId?: string,
95
+ ): Promise<number>;
96
+ appendSessionEventBatch(
97
+ runId: string,
98
+ events: WorkflowSessionEventRecord[],
99
+ attemptId?: string,
100
+ ): Promise<void>;
101
+ sessionCounts(
102
+ runId: string,
103
+ attemptId?: string,
104
+ ): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }>;
105
+ };
106
+
107
+ /** Records settled Pi entries and normalized temporal events through the workflow host. */
108
+ export class SessionRecorder {
109
+ private readonly store: SessionRecordingStore;
110
+ private readonly runId: string;
111
+ /** Capture segment for this recorder after an ownership handoff. */
112
+ private segmentId: string | undefined;
113
+ private cursor: string | null = null;
114
+ private readonly recorded: string[] = [];
115
+ private readonly unclaimedEntries: RecordedEntry[] = [];
116
+ private bound = false;
117
+ private acceptingEntries = true;
118
+ private acceptingEvents = true;
119
+ private entryChain: Promise<unknown> = Promise.resolve();
120
+ private stopPromise: Promise<void> | null = null;
121
+
122
+ private nextEventSeq = 1;
123
+ private nextTurnId = 1;
124
+ private nextMessageId = 1;
125
+ private currentAttempt: AttemptOwner | null = null;
126
+ private currentTurn: TurnOwner | null = null;
127
+ private currentMessage: MessageOwner | null = null;
128
+ private lastFinishedAttempt: AttemptOwner | null = null;
129
+ private finishPromise: Promise<void> | null = null;
130
+ private resolveFinish: (() => void) | null = null;
131
+ private finishTimer: NodeJS.Timeout | null = null;
132
+ private readonly messageOwners = new WeakMap<object, MessageOwner>();
133
+ private readonly stableMessageOwners = new Map<string, MessageOwner>();
134
+ private readonly toolOwners = new Map<string, MessageOwner>();
135
+ private readonly turnToolCallIds = new Map<string, string[]>();
136
+
137
+ private eventQueue: QueuedEvent[] = [];
138
+ private readonly pendingMessageEnds: PendingMessageEnd[] = [];
139
+ private outstandingRecords = 0;
140
+ private outstandingBytes = 0;
141
+ private flushTimer: NodeJS.Timeout | null = null;
142
+ private flushPromise: Promise<void> | null = null;
143
+ private captureFailure: WorkflowSessionCaptureFailure | null = null;
144
+
145
+ constructor(store: SessionRecordingStore, runId: string) {
146
+ this.store = store;
147
+ this.runId = runId;
148
+ }
149
+
150
+ async bind(ctx: ExtensionContext): Promise<void> {
151
+ if (this.bound) {
152
+ return;
153
+ }
154
+ this.bound = true;
155
+ this.cursor = ctx.sessionManager.getLeafId();
156
+ const sessionFile = ctx.sessionManager.getSessionFile();
157
+ // An existing binding belongs to an earlier capture segment.
158
+ if (await this.store.hasSessionBinding(this.runId)) {
159
+ this.segmentId = randomUUID();
160
+ }
161
+ await this.store.writeSessionBinding(
162
+ this.runId,
163
+ {
164
+ schema: SESSION_BINDING_SCHEMA,
165
+ runId: this.runId,
166
+ piSessionId: ctx.sessionManager.getSessionId(),
167
+ ...(sessionFile !== undefined ? { piSessionFile: sessionFile } : {}),
168
+ cwd: ctx.cwd,
169
+ boundAt: new Date().toISOString(),
170
+ },
171
+ this.segmentId,
172
+ );
173
+ await this.store.writeSessionCapture(
174
+ this.runId,
175
+ {
176
+ schema: SESSION_CAPTURE_SCHEMA,
177
+ eventSchema: SESSION_EVENT_SCHEMA,
178
+ status: "recording",
179
+ eventCount: 0,
180
+ entryCount: 0,
181
+ lastEventSeq: 0,
182
+ },
183
+ this.segmentId,
184
+ );
185
+ }
186
+
187
+ /** Fix the owner before the executor delivers an agent-step prompt. */
188
+ beginAttempt(contract: AgentStepContract): void {
189
+ this.currentAttempt = { nodeId: contract.nodeId, attemptId: contract.attemptId };
190
+ }
191
+
192
+ /** Record a workflow-owned turn that is not tied to one engine node attempt. */
193
+ beginWorkflowMessage(workflowMessageId: string, kind: "terminal" | "followUp"): void {
194
+ this.currentAttempt = { nodeId: kind, attemptId: workflowMessageId };
195
+ }
196
+
197
+ /** Release ownership of the turn that Pi has fully settled. */
198
+ settleAttempt(): void {
199
+ const finished = this.lastFinishedAttempt;
200
+ this.lastFinishedAttempt = null;
201
+ if (
202
+ this.currentTurn === null &&
203
+ (finished === null ||
204
+ (this.currentAttempt?.nodeId === finished.nodeId &&
205
+ this.currentAttempt.attemptId === finished.attemptId))
206
+ ) {
207
+ this.currentAttempt = null;
208
+ }
209
+ }
210
+
211
+ handleTurnStart(event: TurnStartEventLike): void {
212
+ const owner = this.currentAttempt;
213
+ if (!owner) {
214
+ return;
215
+ }
216
+ const turn: TurnOwner = {
217
+ ...owner,
218
+ turnId: `t${this.nextTurnId}`,
219
+ turnIndex: event.turnIndex,
220
+ };
221
+ this.nextTurnId += 1;
222
+ this.currentTurn = turn;
223
+ this.turnToolCallIds.set(turn.turnId, []);
224
+ this.enqueue(turn, "turn_started", { turnIndex: event.turnIndex });
225
+ }
226
+
227
+ async handleTurnEnd(event: TurnEndEventLike, ctx: ExtensionContext): Promise<void> {
228
+ const turn = this.currentTurn;
229
+ if (!turn) {
230
+ return;
231
+ }
232
+ const message = this.ownerForMessage(event.message);
233
+ try {
234
+ await this.synchronize(ctx);
235
+ this.enqueue(
236
+ turn,
237
+ "turn_finished",
238
+ turnFinishedPayload(event, message?.messageId, this.turnToolCallIds.get(turn.turnId) ?? []),
239
+ );
240
+ } catch (error) {
241
+ this.failCapture("entry_write_failed", failureMessage(error));
242
+ }
243
+ this.currentTurn = null;
244
+ this.currentMessage = null;
245
+ this.lastFinishedAttempt = { nodeId: turn.nodeId, attemptId: turn.attemptId };
246
+ if (this.finishPromise) {
247
+ await this.stop();
248
+ }
249
+ }
250
+
251
+ async handleMessageStart(event: MessageStartEventLike, ctx: ExtensionContext): Promise<void> {
252
+ const turn = this.currentTurn;
253
+ if (!turn) {
254
+ return;
255
+ }
256
+ await this.synchronize(ctx);
257
+ const owner: MessageOwner = {
258
+ ...turn,
259
+ messageId: `m${this.nextMessageId}`,
260
+ role: messageRole(event.message),
261
+ };
262
+ this.nextMessageId += 1;
263
+ const key = objectKey(event.message);
264
+ if (key) {
265
+ this.messageOwners.set(key, owner);
266
+ }
267
+ const stableKey = stableMessageKey(event.message);
268
+ if (stableKey) {
269
+ this.stableMessageOwners.set(stableKey, owner);
270
+ }
271
+ this.currentMessage = owner;
272
+ this.enqueue(owner, "message_started", { role: owner.role });
273
+ }
274
+
275
+ handleMessageUpdate(event: MessageUpdateEventLike): void {
276
+ const owner = this.ownerForMessage(event.message);
277
+ if (!owner) {
278
+ return;
279
+ }
280
+ const normalized = normalizeAssistantEvent(event.assistantMessageEvent);
281
+ if (
282
+ normalized.type === "text_delta" ||
283
+ normalized.type === "thinking_delta" ||
284
+ normalized.type === "toolcall_delta"
285
+ ) {
286
+ return;
287
+ }
288
+ const toolCallId = toolCallIdFromAssistantEvent(normalized);
289
+ if (toolCallId) {
290
+ this.toolOwners.set(toolCallId, owner);
291
+ const ids = this.turnToolCallIds.get(owner.turnId) ?? [];
292
+ if (!ids.includes(toolCallId)) {
293
+ ids.push(toolCallId);
294
+ this.turnToolCallIds.set(owner.turnId, ids);
295
+ }
296
+ }
297
+ this.enqueue(owner, "assistant_event", normalized as unknown as Record<string, unknown>);
298
+ }
299
+
300
+ handleMessageEnd(event: MessageEndEventLike): void {
301
+ const owner = this.ownerForMessage(event.message);
302
+ if (!owner) {
303
+ return;
304
+ }
305
+ // Pi appends the final session entry after message_end handlers return.
306
+ // Keep this record and every later event queued until a documented hook
307
+ // with synchronized session state can attach the exact entry id.
308
+ this.clearFlushTimer();
309
+ const queued = this.enqueue(
310
+ owner,
311
+ "message_finished",
312
+ { role: owner.role, settled: false },
313
+ undefined,
314
+ {},
315
+ true,
316
+ );
317
+ if (queued) {
318
+ this.pendingMessageEnds.push({ queued, role: owner.role, message: event.message });
319
+ this.clearFlushTimer();
320
+ }
321
+ if (this.currentMessage?.messageId === owner.messageId) {
322
+ this.currentMessage = null;
323
+ }
324
+ }
325
+
326
+ handleToolStart(event: ToolExecutionStartEventLike): void {
327
+ const owner = this.toolOwners.get(event.toolCallId) ?? this.currentMessage;
328
+ if (!owner) {
329
+ return;
330
+ }
331
+ this.toolOwners.set(event.toolCallId, owner);
332
+ this.enqueue(owner, "tool_execution_started", toolStartedPayload(event), undefined, {
333
+ toolCallId: event.toolCallId,
334
+ });
335
+ }
336
+
337
+ handleToolUpdate(_event: ToolExecutionUpdateEventLike): void {
338
+ // Incremental tool progress is transient. The recorder stores the settled
339
+ // tool result and the surrounding lifecycle facts.
340
+ }
341
+
342
+ handleToolEnd(event: ToolExecutionEndEventLike): void {
343
+ const owner = this.toolOwners.get(event.toolCallId);
344
+ if (!owner) {
345
+ return;
346
+ }
347
+ this.enqueue(owner, "tool_execution_finished", toolFinishedPayload(event), undefined, {
348
+ toolCallId: event.toolCallId,
349
+ });
350
+ }
351
+
352
+ /** Flush new entries on the current branch into the bundle. */
353
+ record(ctx: ExtensionContext): Promise<RecordedEntry[]> {
354
+ if (!this.bound || !this.acceptingEntries) {
355
+ return Promise.resolve([]);
356
+ }
357
+ const task = this.entryChain.then(async () => {
358
+ const branch = ctx.sessionManager.getBranch() as unknown as Array<
359
+ Record<string, unknown> & { id: string }
360
+ >;
361
+ let startIndex = 0;
362
+ if (this.cursor !== null) {
363
+ const cursorIndex = branch.findIndex((entry) => entry.id === this.cursor);
364
+ if (cursorIndex === -1) {
365
+ this.cursor = branch.at(-1)?.id ?? this.cursor;
366
+ return [];
367
+ }
368
+ startIndex = cursorIndex + 1;
369
+ }
370
+ const appended: RecordedEntry[] = [];
371
+ for (const entry of branch.slice(startIndex)) {
372
+ await this.store.appendSessionEntry(this.runId, entry, this.segmentId);
373
+ const recorded = { id: entry.id, entry, claimed: false };
374
+ appended.push(recorded);
375
+ this.unclaimedEntries.push(recorded);
376
+ this.recorded.push(entry.id);
377
+ this.cursor = entry.id;
378
+ }
379
+ return appended;
380
+ });
381
+ this.entryChain = task.catch((error: unknown) => {
382
+ this.failCapture("entry_write_failed", failureMessage(error));
383
+ });
384
+ return task;
385
+ }
386
+
387
+ /** Record durable entries, then release deferred message-end events in order. */
388
+ async synchronize(ctx: ExtensionContext): Promise<void> {
389
+ if (this.pendingMessageEnds.length === 0) {
390
+ return;
391
+ }
392
+ await this.record(ctx);
393
+ for (const pending of this.pendingMessageEnds.splice(0)) {
394
+ const entryId = this.claimEntry(pending.role, pending.message);
395
+ pending.queued.record.payload = entryId
396
+ ? { role: pending.role, settled: true, entryId }
397
+ : { role: pending.role, settled: false };
398
+ const bytes = Buffer.byteLength(JSON.stringify(pending.queued.record), "utf8") + 1;
399
+ this.outstandingBytes += bytes - pending.queued.bytes;
400
+ pending.queued.bytes = bytes;
401
+ }
402
+ if (this.eventQueue.length >= FLUSH_MAX_RECORDS || this.queuedBytes() >= FLUSH_MAX_BYTES) {
403
+ this.startFlush();
404
+ } else {
405
+ this.scheduleFlush();
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Finalize immediately when no Pi turn is active. Otherwise wait for
411
+ * `turn_end`, which is the last documented lifecycle boundary needed to
412
+ * include the workflow tool result and any following assistant output.
413
+ */
414
+ async finish(): Promise<void> {
415
+ if (this.stopPromise || this.currentTurn === null) {
416
+ return await this.stop();
417
+ }
418
+ if (!this.finishPromise) {
419
+ this.finishPromise = new Promise<void>((resolve) => {
420
+ this.resolveFinish = resolve;
421
+ });
422
+ this.finishTimer = setTimeout(() => {
423
+ this.failCapture(
424
+ "turn_settle_timeout",
425
+ `active Pi turn did not finish within ${FINISH_TURN_TIMEOUT_MS}ms`,
426
+ );
427
+ void this.stop();
428
+ }, FINISH_TURN_TIMEOUT_MS);
429
+ this.finishTimer.unref?.();
430
+ }
431
+ return await this.finishPromise;
432
+ }
433
+
434
+ async stop(): Promise<void> {
435
+ if (this.stopPromise) {
436
+ return await this.stopPromise;
437
+ }
438
+ if (this.currentTurn !== null) {
439
+ this.failCapture(
440
+ "turn_interrupted",
441
+ "session capture stopped before the active Pi turn finished",
442
+ );
443
+ }
444
+ this.acceptingEntries = false;
445
+ this.acceptingEvents = false;
446
+ // If Pi closes before another synchronized hook, preserve the message-end
447
+ // receipts as explicitly unsettled rather than hanging finalization.
448
+ this.pendingMessageEnds.splice(0);
449
+ this.clearFlushTimer();
450
+ this.stopPromise = (async () => {
451
+ try {
452
+ await this.flushAllEvents();
453
+ await this.entryChain;
454
+ const counts = await this.store.sessionCounts(this.runId, this.segmentId);
455
+ await this.store.writeSessionCapture(
456
+ this.runId,
457
+ {
458
+ schema: SESSION_CAPTURE_SCHEMA,
459
+ eventSchema: SESSION_EVENT_SCHEMA,
460
+ status: this.captureFailure ? "failed" : "complete",
461
+ ...counts,
462
+ ...(this.captureFailure ? { failure: this.captureFailure } : {}),
463
+ },
464
+ this.segmentId,
465
+ );
466
+ } catch (error) {
467
+ // Capture is observational. A finalization failure must never reject
468
+ // the workflow's terminal persistence hook.
469
+ this.failCapture("capture_finalize_failed", failureMessage(error));
470
+ try {
471
+ const counts = await this.store.sessionCounts(this.runId, this.segmentId);
472
+ await this.store.writeSessionCapture(
473
+ this.runId,
474
+ {
475
+ schema: SESSION_CAPTURE_SCHEMA,
476
+ eventSchema: SESSION_EVENT_SCHEMA,
477
+ status: "failed",
478
+ ...counts,
479
+ failure: this.captureFailure ?? {
480
+ failedAt: new Date().toISOString(),
481
+ code: "capture_finalize_failed",
482
+ message: failureMessage(error),
483
+ },
484
+ },
485
+ this.segmentId,
486
+ );
487
+ } catch {
488
+ // The viewer will report the missing/invalid capture file.
489
+ }
490
+ } finally {
491
+ if (this.finishTimer) {
492
+ clearTimeout(this.finishTimer);
493
+ this.finishTimer = null;
494
+ }
495
+ this.resolveFinish?.();
496
+ this.resolveFinish = null;
497
+ }
498
+ })();
499
+ return await this.stopPromise;
500
+ }
501
+
502
+ mark(): number {
503
+ return this.recorded.length;
504
+ }
505
+
506
+ rangeSince(mark: number): ConversationRange | undefined {
507
+ if (this.recorded.length <= mark) {
508
+ return undefined;
509
+ }
510
+ return {
511
+ firstEntryId: this.recorded[mark] as string,
512
+ lastEntryId: this.recorded.at(-1) as string,
513
+ };
514
+ }
515
+
516
+ private ownerForMessage(message: unknown): MessageOwner | null {
517
+ const key = objectKey(message);
518
+ const stableKey = stableMessageKey(message);
519
+ return (
520
+ (key ? this.messageOwners.get(key) : undefined) ??
521
+ (stableKey ? this.stableMessageOwners.get(stableKey) : undefined) ??
522
+ this.currentMessage
523
+ );
524
+ }
525
+
526
+ private claimEntry(role: string, message: unknown): string | undefined {
527
+ const messageKey = stableMessageKey(message);
528
+ const entry = this.unclaimedEntries.find((candidate) => {
529
+ if (candidate.claimed || entryRole(candidate.entry) !== role) {
530
+ return false;
531
+ }
532
+ return messageKey === null || stableMessageKey(candidate.entry.message) === messageKey;
533
+ });
534
+ if (!entry) {
535
+ return undefined;
536
+ }
537
+ entry.claimed = true;
538
+ return entry.id;
539
+ }
540
+
541
+ private enqueue(
542
+ owner: TurnOwner | MessageOwner,
543
+ type: WorkflowSessionEventType,
544
+ payload: Record<string, unknown>,
545
+ at: string = new Date().toISOString(),
546
+ extra: { toolCallId?: string } = {},
547
+ deferFlush = false,
548
+ ): QueuedEvent | null {
549
+ if (!this.bound || !this.acceptingEvents || this.captureFailure) {
550
+ return null;
551
+ }
552
+ const messageId = "messageId" in owner ? owner.messageId : undefined;
553
+ const record: WorkflowSessionEventRecord = {
554
+ seq: this.nextEventSeq,
555
+ at,
556
+ nodeId: owner.nodeId,
557
+ attemptId: owner.attemptId,
558
+ turnId: owner.turnId,
559
+ ...(messageId === undefined ? {} : { messageId }),
560
+ ...(extra.toolCallId === undefined ? {} : { toolCallId: extra.toolCallId }),
561
+ type,
562
+ payload,
563
+ };
564
+ const bytes = Buffer.byteLength(JSON.stringify(record), "utf8") + 1;
565
+ const externalizable =
566
+ type === "tool_execution_started" ||
567
+ type === "tool_execution_finished" ||
568
+ (type === "assistant_event" && payload.type === "toolcall_end");
569
+ if (bytes > SESSION_EVENT_MAX_BYTES && !externalizable) {
570
+ this.failCapture(
571
+ "event_too_large",
572
+ `session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`,
573
+ );
574
+ return null;
575
+ }
576
+ if (
577
+ this.outstandingRecords + 1 > QUEUE_MAX_RECORDS ||
578
+ this.outstandingBytes + bytes > QUEUE_MAX_BYTES
579
+ ) {
580
+ this.failCapture("event_queue_overflow", "session event queue limit exceeded");
581
+ return null;
582
+ }
583
+ this.nextEventSeq += 1;
584
+ const queued = { record, bytes };
585
+ this.eventQueue.push(queued);
586
+ this.outstandingRecords += 1;
587
+ this.outstandingBytes += bytes;
588
+ if (!deferFlush) {
589
+ if (this.eventQueue.length >= FLUSH_MAX_RECORDS || this.queuedBytes() >= FLUSH_MAX_BYTES) {
590
+ this.startFlush();
591
+ } else {
592
+ this.scheduleFlush();
593
+ }
594
+ }
595
+ return queued;
596
+ }
597
+
598
+ private queuedBytes(): number {
599
+ return this.eventQueue.reduce((sum, queued) => sum + queued.bytes, 0);
600
+ }
601
+
602
+ private scheduleFlush(): void {
603
+ if (this.pendingMessageEnds.length > 0 || this.flushTimer || this.flushPromise) {
604
+ return;
605
+ }
606
+ this.flushTimer = setTimeout(() => {
607
+ this.flushTimer = null;
608
+ this.startFlush();
609
+ }, FLUSH_INTERVAL_MS);
610
+ this.flushTimer.unref?.();
611
+ }
612
+
613
+ private startFlush(): void {
614
+ if (this.pendingMessageEnds.length > 0 || this.flushPromise || this.eventQueue.length === 0) {
615
+ return;
616
+ }
617
+ this.clearFlushTimer();
618
+ const batch: QueuedEvent[] = [];
619
+ let bytes = 0;
620
+ while (this.eventQueue.length > 0 && batch.length < FLUSH_MAX_RECORDS) {
621
+ const next = this.eventQueue[0] as QueuedEvent;
622
+ if (batch.length > 0 && bytes + next.bytes > FLUSH_MAX_BYTES) {
623
+ break;
624
+ }
625
+ batch.push(this.eventQueue.shift() as QueuedEvent);
626
+ bytes += next.bytes;
627
+ }
628
+ this.flushPromise = this.store
629
+ .appendSessionEventBatch(
630
+ this.runId,
631
+ batch.map((queued) => queued.record),
632
+ this.segmentId,
633
+ )
634
+ .catch((error: unknown) => {
635
+ const message = failureMessage(error);
636
+ this.failCapture(
637
+ message.includes(`exceeded ${SESSION_EVENT_MAX_BYTES} bytes`)
638
+ ? "event_too_large"
639
+ : "event_write_failed",
640
+ message,
641
+ );
642
+ for (const queued of this.eventQueue.splice(0)) {
643
+ this.outstandingRecords -= 1;
644
+ this.outstandingBytes -= queued.bytes;
645
+ }
646
+ })
647
+ .finally(() => {
648
+ for (const queued of batch) {
649
+ this.outstandingRecords -= 1;
650
+ this.outstandingBytes -= queued.bytes;
651
+ }
652
+ this.flushPromise = null;
653
+ if (this.eventQueue.length > 0 && this.captureFailure?.code !== "event_write_failed") {
654
+ this.startFlush();
655
+ }
656
+ });
657
+ }
658
+
659
+ private async flushAllEvents(): Promise<void> {
660
+ this.clearFlushTimer();
661
+ while (this.eventQueue.length > 0 || this.flushPromise) {
662
+ if (!this.flushPromise) {
663
+ this.startFlush();
664
+ }
665
+ await this.flushPromise;
666
+ }
667
+ }
668
+
669
+ private clearFlushTimer(): void {
670
+ if (this.flushTimer) {
671
+ clearTimeout(this.flushTimer);
672
+ this.flushTimer = null;
673
+ }
674
+ }
675
+
676
+ private failCapture(code: string, message: string): void {
677
+ if (this.captureFailure) {
678
+ return;
679
+ }
680
+ this.captureFailure = {
681
+ failedAt: new Date().toISOString(),
682
+ code,
683
+ message,
684
+ };
685
+ this.acceptingEvents = false;
686
+ }
687
+ }