@overmux/pi 0.0.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 (70) hide show
  1. package/README.md +118 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +40 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/config-2jNv4tol.d.ts +34 -0
  6. package/dist/config-2jNv4tol.d.ts.map +1 -0
  7. package/dist/config.d.ts +2 -0
  8. package/dist/config.js +47 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/extension.d.ts +19 -0
  11. package/dist/extension.d.ts.map +1 -0
  12. package/dist/extension.js +265 -0
  13. package/dist/extension.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +16 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/jsonl-tail.d.ts +13 -0
  19. package/dist/jsonl-tail.d.ts.map +1 -0
  20. package/dist/jsonl-tail.js +83 -0
  21. package/dist/jsonl-tail.js.map +1 -0
  22. package/dist/live-events-ClhFOMGW.js +497 -0
  23. package/dist/live-events-ClhFOMGW.js.map +1 -0
  24. package/dist/live-events-DAmf6RRx.d.ts +107 -0
  25. package/dist/live-events-DAmf6RRx.d.ts.map +1 -0
  26. package/dist/live-events.d.ts +2 -0
  27. package/dist/live-events.js +2 -0
  28. package/dist/notification-DzOd9cRc.d.ts +20 -0
  29. package/dist/notification-DzOd9cRc.d.ts.map +1 -0
  30. package/dist/notification.d.ts +2 -0
  31. package/dist/notification.js +54 -0
  32. package/dist/notification.js.map +1 -0
  33. package/dist/plugin.d.ts +90 -0
  34. package/dist/plugin.d.ts.map +1 -0
  35. package/dist/plugin.js +213 -0
  36. package/dist/plugin.js.map +1 -0
  37. package/dist/projection.d.ts +101 -0
  38. package/dist/projection.d.ts.map +1 -0
  39. package/dist/projection.js +550 -0
  40. package/dist/projection.js.map +1 -0
  41. package/dist/protocol-CsrnSPOv.d.ts +115 -0
  42. package/dist/protocol-CsrnSPOv.d.ts.map +1 -0
  43. package/dist/protocol.d.ts +2 -0
  44. package/dist/protocol.js +365 -0
  45. package/dist/protocol.js.map +1 -0
  46. package/dist/react.d.ts +139 -0
  47. package/dist/react.d.ts.map +1 -0
  48. package/dist/react.js +796 -0
  49. package/dist/react.js.map +1 -0
  50. package/dist/server.d.ts +5 -0
  51. package/dist/server.js +4 -0
  52. package/dist/session-status-CZLTo8Km.d.ts +71 -0
  53. package/dist/session-status-CZLTo8Km.d.ts.map +1 -0
  54. package/dist/styles.css +619 -0
  55. package/docs/index.md +10 -0
  56. package/package.json +115 -0
  57. package/src/cli.ts +59 -0
  58. package/src/config.ts +87 -0
  59. package/src/extension.ts +486 -0
  60. package/src/index.ts +14 -0
  61. package/src/jsonl-tail.ts +110 -0
  62. package/src/live-events.ts +578 -0
  63. package/src/notification.ts +109 -0
  64. package/src/plugin.ts +369 -0
  65. package/src/projection.ts +995 -0
  66. package/src/protocol.ts +805 -0
  67. package/src/react.tsx +1293 -0
  68. package/src/server.ts +15 -0
  69. package/src/session-status.ts +379 -0
  70. package/src/styles.css +619 -0
@@ -0,0 +1,995 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import { z } from "zod";
6
+ import {
7
+ parseLiveEventRecord,
8
+ type LiveEventWireRecord as LiveRecord,
9
+ } from "./live-events.js";
10
+ import {
11
+ probePiSession,
12
+ sendUserMessage,
13
+ type UserMessageInput,
14
+ type UserMessageResponse,
15
+ } from "./protocol.js";
16
+ import { createJsonlTail, type JsonlTail } from "./jsonl-tail.js";
17
+
18
+ export type PiContentBlock = {
19
+ arguments?: unknown;
20
+ data?: string;
21
+ id?: string;
22
+ mimeType?: string;
23
+ name?: string;
24
+ text?: string;
25
+ thinking?: string;
26
+ tool?: PiToolProjection;
27
+ type: string;
28
+ };
29
+ export type PiToolResultProjection = {
30
+ content: PiContentBlock[];
31
+ details?: unknown;
32
+ isError: boolean;
33
+ toolCallId: string;
34
+ toolName: string;
35
+ };
36
+ export type PiToolProjection = {
37
+ result?: PiToolResultProjection;
38
+ status: "error" | "pending" | "success";
39
+ };
40
+ export type PiConversationEntry = {
41
+ content: PiContentBlock[];
42
+ details?: unknown;
43
+ errorMessage?: string;
44
+ id: string;
45
+ isError?: boolean;
46
+ role: "assistant" | "toolResult" | "user";
47
+ source: "canonical" | "live";
48
+ status: "complete" | "error" | "pending";
49
+ stopReason?: string;
50
+ timestamp?: number;
51
+ toolCallId?: string;
52
+ toolName?: string;
53
+ };
54
+
55
+ export const piContentBlockSchema: z.ZodType<PiContentBlock> = z.lazy(() =>
56
+ z.object({
57
+ arguments: z.unknown().optional(),
58
+ data: z.string().optional(),
59
+ id: z.string().optional(),
60
+ mimeType: z.string().optional(),
61
+ name: z.string().optional(),
62
+ text: z.string().optional(),
63
+ thinking: z.string().optional(),
64
+ tool: piToolProjectionSchema.optional(),
65
+ type: z.string(),
66
+ }),
67
+ );
68
+
69
+ export const piToolResultProjectionSchema: z.ZodType<PiToolResultProjection> =
70
+ z.object({
71
+ content: z.array(piContentBlockSchema),
72
+ details: z.unknown().optional(),
73
+ isError: z.boolean(),
74
+ toolCallId: z.string(),
75
+ toolName: z.string(),
76
+ });
77
+
78
+ export const piToolProjectionSchema: z.ZodType<PiToolProjection> = z.lazy(() =>
79
+ z.object({
80
+ result: piToolResultProjectionSchema.optional(),
81
+ status: z.enum(["error", "pending", "success"]),
82
+ }),
83
+ );
84
+ export const piSessionMetadataSchema = z
85
+ .object({
86
+ contextUsage: z
87
+ .object({
88
+ tokens: z.number().nonnegative(),
89
+ contextWindow: z.number().positive(),
90
+ percent: z.number().nonnegative(),
91
+ })
92
+ .strict()
93
+ .optional(),
94
+ model: z
95
+ .object({
96
+ provider: z.string().min(1),
97
+ id: z.string().min(1),
98
+ name: z.string().min(1),
99
+ })
100
+ .strict()
101
+ .optional(),
102
+ thinkingLevel: z.string().min(1),
103
+ modelOptions: z
104
+ .array(
105
+ z
106
+ .object({
107
+ provider: z.string().min(1),
108
+ id: z.string().min(1),
109
+ name: z.string().min(1),
110
+ })
111
+ .strict(),
112
+ )
113
+ .max(256)
114
+ .optional(),
115
+ })
116
+ .strict();
117
+
118
+ export type PiSessionMetadata = z.infer<typeof piSessionMetadataSchema>;
119
+ export type PiConversationSnapshot = {
120
+ agentAvailable: boolean;
121
+ entries: PiConversationEntry[];
122
+ sessionMetadata?: PiSessionMetadata;
123
+ status: "busy" | "degraded" | "idle" | "offline";
124
+ };
125
+
126
+ export const piConversationSnapshotSchema: z.ZodType<PiConversationSnapshot> =
127
+ z.object({
128
+ agentAvailable: z.boolean(),
129
+ entries: z.array(
130
+ z.object({
131
+ content: z.array(piContentBlockSchema),
132
+ details: z.unknown().optional(),
133
+ errorMessage: z.string().optional(),
134
+ id: z.string(),
135
+ isError: z.boolean().optional(),
136
+ role: z.enum(["assistant", "toolResult", "user"]),
137
+ source: z.enum(["canonical", "live"]),
138
+ status: z.enum(["complete", "error", "pending"]),
139
+ stopReason: z.string().optional(),
140
+ timestamp: z.number().optional(),
141
+ toolCallId: z.string().optional(),
142
+ toolName: z.string().optional(),
143
+ }),
144
+ ),
145
+ sessionMetadata: piSessionMetadataSchema.optional(),
146
+ status: z.enum(["busy", "degraded", "idle", "offline"]),
147
+ });
148
+
149
+ export type PiAgentSession = {
150
+ id: string;
151
+ liveEventsDir?: string;
152
+ sessionFile: string;
153
+ sessionMetadata?: PiSessionMetadata;
154
+ };
155
+
156
+ type PiAgentResource = PiAgentSession & { sessionId: string };
157
+
158
+ type PiMessage = {
159
+ content?: string | unknown[];
160
+ details?: unknown;
161
+ errorMessage?: string;
162
+ isError?: boolean;
163
+ role?: string;
164
+ stopReason?: string;
165
+ timestamp?: number | string;
166
+ toolCallId?: string;
167
+ toolName?: string;
168
+ };
169
+
170
+ type StreamState = {
171
+ conflict: boolean;
172
+ records: Map<number, LiveRecord>;
173
+ tail: JsonlTail;
174
+ };
175
+ type LiveMessageState = {
176
+ blocks: PiContentBlock[];
177
+ ended: boolean;
178
+ id: string;
179
+ message?: PiMessage;
180
+ messageSequence: number;
181
+ sourceOrder: number;
182
+ status: PiConversationEntry["status"];
183
+ streamId: string;
184
+ timestamp?: number;
185
+ };
186
+ type ToolState = {
187
+ isError?: boolean;
188
+ result?: unknown;
189
+ status: PiToolProjection["status"];
190
+ toolCallId: string;
191
+ toolName: string;
192
+ };
193
+ type ProjectionState = {
194
+ agentAvailable?: boolean;
195
+ canonicalRecords: unknown[];
196
+ canonicalTail: JsonlTail;
197
+ configKey: string;
198
+ refreshQueue: Promise<void>;
199
+ sessionMetadata?: PiSessionMetadata;
200
+ snapshot?: PiConversationSnapshot;
201
+ streams: Map<string, StreamState>;
202
+ };
203
+ type SnapshotListener = (snapshot: PiConversationSnapshot) => void;
204
+
205
+ export type PiAgentConversationService = {
206
+ dispose: () => void;
207
+ getSnapshot: (agentId: string) => Promise<PiConversationSnapshot>;
208
+ subscribe: (agentId: string, listener: SnapshotListener) => () => void;
209
+ };
210
+
211
+ type PiMessageResponse = Extract<UserMessageResponse, { ok: true }>;
212
+
213
+ export type PiMessageSender = (
214
+ sessionId: string,
215
+ request: UserMessageInput,
216
+ ) => Promise<UserMessageResponse | undefined>;
217
+
218
+ const isObject = (value: unknown): value is Record<string, unknown> =>
219
+ typeof value === "object" && value !== null && !Array.isArray(value);
220
+
221
+ const timestampOf = (value: unknown): number | undefined => {
222
+ if (typeof value === "number" && Number.isFinite(value)) {
223
+ return value;
224
+ }
225
+ if (typeof value !== "string") {
226
+ return undefined;
227
+ }
228
+ const timestamp = Date.parse(value);
229
+ return Number.isNaN(timestamp) ? undefined : timestamp;
230
+ };
231
+
232
+ const contentBlocksOf = (content: PiMessage["content"]): PiContentBlock[] => {
233
+ if (typeof content === "string") {
234
+ return [{ text: content, type: "text" }];
235
+ }
236
+ if (!Array.isArray(content)) {
237
+ return [];
238
+ }
239
+ return content.flatMap((value) => {
240
+ if (!isObject(value) || typeof value.type !== "string") {
241
+ return [];
242
+ }
243
+ return [{ ...value, type: value.type } as PiContentBlock];
244
+ });
245
+ };
246
+
247
+ const browserToolDetails = (details: unknown) => {
248
+ if (!isObject(details)) {
249
+ return details;
250
+ }
251
+ const { fullOutputPath: _fullOutputPath, ...safeDetails } = details;
252
+ return safeDetails;
253
+ };
254
+
255
+ const toolResultOf = (
256
+ message: PiMessage,
257
+ ): PiToolResultProjection | undefined => {
258
+ if (
259
+ message.role !== "toolResult" ||
260
+ typeof message.toolCallId !== "string" ||
261
+ typeof message.toolName !== "string"
262
+ ) {
263
+ return undefined;
264
+ }
265
+ return {
266
+ content: contentBlocksOf(message.content),
267
+ details: browserToolDetails(message.details),
268
+ isError: message.isError === true,
269
+ toolCallId: message.toolCallId,
270
+ toolName: message.toolName,
271
+ };
272
+ };
273
+
274
+ const entryOf = (
275
+ value: unknown,
276
+ source: PiConversationEntry["source"],
277
+ ): PiConversationEntry | undefined => {
278
+ if (
279
+ !isObject(value) ||
280
+ value.type !== "message" ||
281
+ !isObject(value.message)
282
+ ) {
283
+ return undefined;
284
+ }
285
+ if (typeof value.id !== "string") {
286
+ return undefined;
287
+ }
288
+ const message = value.message as PiMessage;
289
+ if (
290
+ message.role !== "assistant" &&
291
+ message.role !== "toolResult" &&
292
+ message.role !== "user"
293
+ ) {
294
+ return undefined;
295
+ }
296
+ return {
297
+ content: contentBlocksOf(message.content),
298
+ details: message.details,
299
+ errorMessage: message.errorMessage,
300
+ id: value.id,
301
+ isError: message.isError,
302
+ role: message.role,
303
+ source,
304
+ status:
305
+ message.isError === true || message.stopReason === "error"
306
+ ? "error"
307
+ : "complete",
308
+ stopReason: message.stopReason,
309
+ timestamp: timestampOf(value.timestamp) ?? timestampOf(message.timestamp),
310
+ toolCallId: message.toolCallId,
311
+ toolName: message.toolName,
312
+ };
313
+ };
314
+
315
+ const appendDelta = (
316
+ blocks: PiContentBlock[],
317
+ event: Record<string, unknown>,
318
+ ) => {
319
+ const update = event.assistantMessageEvent;
320
+ if (!isObject(update) || !Number.isSafeInteger(update.contentIndex)) {
321
+ return;
322
+ }
323
+ const index = update.contentIndex as number;
324
+ if (index < 0) {
325
+ return;
326
+ }
327
+ const current = blocks[index] ?? { type: "text" };
328
+ if (update.type === "text_start") {
329
+ blocks[index] = { type: "text" };
330
+ }
331
+ if (update.type === "thinking_start") {
332
+ blocks[index] = { type: "thinking" };
333
+ }
334
+ if (update.type === "toolcall_start") {
335
+ blocks[index] = { type: "toolCall" };
336
+ }
337
+ if (update.type === "text_delta") {
338
+ blocks[index] = {
339
+ ...current,
340
+ text: `${current.text ?? ""}${typeof update.delta === "string" ? update.delta : ""}`,
341
+ type: "text",
342
+ };
343
+ }
344
+ if (update.type === "thinking_delta") {
345
+ blocks[index] = {
346
+ ...current,
347
+ thinking: `${current.thinking ?? ""}${typeof update.delta === "string" ? update.delta : ""}`,
348
+ type: "thinking",
349
+ };
350
+ }
351
+ if (update.type === "toolcall_delta") {
352
+ blocks[index] = {
353
+ ...current,
354
+ arguments: `${typeof current.arguments === "string" ? current.arguments : ""}${typeof update.delta === "string" ? update.delta : ""}`,
355
+ type: "toolCall",
356
+ };
357
+ }
358
+ };
359
+
360
+ const updateMessage = (
361
+ messages: Map<string, LiveMessageState>,
362
+ record: LiveRecord,
363
+ sourceOrder: number,
364
+ ) => {
365
+ const event = record.event;
366
+ if (typeof event.messageId !== "string") {
367
+ return;
368
+ }
369
+ const key = `${record.streamId}\0${event.messageId}`;
370
+ const current = messages.get(key) ?? {
371
+ blocks: [],
372
+ ended: false,
373
+ id: key,
374
+ messageSequence:
375
+ typeof event.messageSequence === "number"
376
+ ? event.messageSequence
377
+ : sourceOrder,
378
+ sourceOrder,
379
+ status: "pending" as const,
380
+ streamId: record.streamId,
381
+ };
382
+ current.timestamp = record.timestamp;
383
+ if (event.type === "message_start" && isObject(event.message)) {
384
+ current.message = event.message as PiMessage;
385
+ }
386
+ if (event.type === "message_update") {
387
+ appendDelta(current.blocks, event);
388
+ const update = event.assistantMessageEvent;
389
+ if (isObject(update) && update.type === "done") {
390
+ current.status = "complete";
391
+ }
392
+ if (isObject(update) && update.type === "error") {
393
+ current.status = "error";
394
+ current.message = {
395
+ ...current.message,
396
+ errorMessage:
397
+ typeof update.errorMessage === "string"
398
+ ? update.errorMessage
399
+ : undefined,
400
+ };
401
+ }
402
+ }
403
+ if (event.type === "message_end" && isObject(event.message)) {
404
+ current.ended = true;
405
+ current.message = event.message as PiMessage;
406
+ current.blocks = contentBlocksOf(current.message.content);
407
+ current.status =
408
+ current.message.isError === true || current.message.stopReason === "error"
409
+ ? "error"
410
+ : "complete";
411
+ }
412
+ messages.set(key, current);
413
+ };
414
+
415
+ const updateTool = (
416
+ tools: Map<string, ToolState>,
417
+ event: Record<string, unknown>,
418
+ ) => {
419
+ if (
420
+ (event.type !== "tool_execution_start" &&
421
+ event.type !== "tool_execution_end") ||
422
+ typeof event.toolCallId !== "string" ||
423
+ typeof event.toolName !== "string"
424
+ ) {
425
+ return;
426
+ }
427
+ tools.set(event.toolCallId, {
428
+ isError: event.type === "tool_execution_end" && event.isError === true,
429
+ result: event.type === "tool_execution_end" ? event.result : undefined,
430
+ status:
431
+ event.type === "tool_execution_start"
432
+ ? "pending"
433
+ : event.isError === true
434
+ ? "error"
435
+ : "success",
436
+ toolCallId: event.toolCallId,
437
+ toolName: event.toolName,
438
+ });
439
+ };
440
+
441
+ const liveEntryOf = (
442
+ state: LiveMessageState,
443
+ ): PiConversationEntry | undefined => {
444
+ const message = state.message;
445
+ if (!message) {
446
+ return undefined;
447
+ }
448
+ const role = message.role;
449
+ if (role !== "assistant" && role !== "toolResult" && role !== "user") {
450
+ return undefined;
451
+ }
452
+ return {
453
+ content: state.ended
454
+ ? contentBlocksOf(message.content)
455
+ : state.blocks.length
456
+ ? state.blocks
457
+ : contentBlocksOf(message.content),
458
+ details: message.details,
459
+ errorMessage: message.errorMessage,
460
+ id: state.id,
461
+ isError: message.isError,
462
+ role,
463
+ source: "live",
464
+ status: state.status,
465
+ stopReason: message.stopReason,
466
+ timestamp: state.timestamp ?? timestampOf(message.timestamp),
467
+ toolCallId: message.toolCallId,
468
+ toolName: message.toolName,
469
+ };
470
+ };
471
+
472
+ const projectLive = (records: LiveRecord[]) => {
473
+ const messages = new Map<string, LiveMessageState>();
474
+ const tools = new Map<string, ToolState>();
475
+ let active = false;
476
+ let shutdown = false;
477
+ records.forEach((record, index) => {
478
+ updateMessage(messages, record, index);
479
+ updateTool(tools, record.event);
480
+ if (record.event.type === "agent_start") {
481
+ active = true;
482
+ }
483
+ if (record.event.type === "agent_settled") {
484
+ active = false;
485
+ }
486
+ if (record.event.type === "session_shutdown") {
487
+ shutdown = true;
488
+ }
489
+ if (record.event.type === "session_start") {
490
+ shutdown = false;
491
+ }
492
+ });
493
+ return {
494
+ active,
495
+ entries: [...messages.values()]
496
+ .sort(
497
+ (left, right) =>
498
+ (left.timestamp ?? 0) - (right.timestamp ?? 0) ||
499
+ left.streamId.localeCompare(right.streamId) ||
500
+ left.messageSequence - right.messageSequence,
501
+ )
502
+ .flatMap((message) => {
503
+ const entry = liveEntryOf(message);
504
+ return entry ? [entry] : [];
505
+ }),
506
+ shutdown,
507
+ tools,
508
+ };
509
+ };
510
+
511
+ const fingerprint = (entry: PiConversationEntry) =>
512
+ JSON.stringify({
513
+ content: entry.content.map(({ tool: _tool, ...block }) => block),
514
+ details: entry.details,
515
+ isError: entry.isError,
516
+ role: entry.role,
517
+ toolCallId: entry.toolCallId,
518
+ toolName: entry.toolName,
519
+ });
520
+
521
+ const matchedLiveIndexes = (
522
+ canonical: PiConversationEntry[],
523
+ live: PiConversationEntry[],
524
+ ) => {
525
+ const liveByFingerprint = new Map<string, number[]>();
526
+ live.forEach((entry, index) => {
527
+ if (entry.status === "pending") {
528
+ return;
529
+ }
530
+ const key = fingerprint(entry);
531
+ const indexes = liveByFingerprint.get(key) ?? [];
532
+ indexes.push(index);
533
+ liveByFingerprint.set(key, indexes);
534
+ });
535
+ const consumed = new Map<string, number>();
536
+ const matched = new Set<number>();
537
+ canonical.forEach((entry) => {
538
+ const key = fingerprint(entry);
539
+ const offset = consumed.get(key) ?? 0;
540
+ const index = liveByFingerprint.get(key)?.[offset];
541
+ if (index !== undefined) {
542
+ matched.add(index);
543
+ consumed.set(key, offset + 1);
544
+ }
545
+ });
546
+ return matched;
547
+ };
548
+
549
+ const contentFromToolEnd = (result: unknown): PiContentBlock[] => {
550
+ if (isObject(result) && "content" in result) {
551
+ return contentBlocksOf(result.content as PiMessage["content"]);
552
+ }
553
+ if (typeof result === "string") {
554
+ return [{ text: result, type: "text" }];
555
+ }
556
+ return result === undefined
557
+ ? []
558
+ : [{ text: JSON.stringify(result, null, 2), type: "text" }];
559
+ };
560
+
561
+ const pairTools = (
562
+ entries: PiConversationEntry[],
563
+ liveTools: Map<string, ToolState>,
564
+ ) => {
565
+ const callIds = new Set(
566
+ entries.flatMap((entry) =>
567
+ entry.content.flatMap((block) =>
568
+ block.type === "toolCall" && typeof block.id === "string"
569
+ ? [block.id]
570
+ : [],
571
+ ),
572
+ ),
573
+ );
574
+ const results = new Map<string, PiToolResultProjection>();
575
+ entries.forEach((entry) => {
576
+ if (entry.role !== "toolResult") {
577
+ return;
578
+ }
579
+ const result = toolResultOf({
580
+ content: entry.content,
581
+ details: entry.details,
582
+ isError: entry.isError,
583
+ role: entry.role,
584
+ toolCallId: entry.toolCallId,
585
+ toolName: entry.toolName,
586
+ });
587
+ if (result) {
588
+ results.set(result.toolCallId, result);
589
+ }
590
+ });
591
+ liveTools.forEach((tool) => {
592
+ if (results.has(tool.toolCallId) || tool.result === undefined) {
593
+ return;
594
+ }
595
+ results.set(tool.toolCallId, {
596
+ content: contentFromToolEnd(tool.result),
597
+ details: isObject(tool.result)
598
+ ? browserToolDetails(tool.result.details)
599
+ : undefined,
600
+ isError: tool.isError === true,
601
+ toolCallId: tool.toolCallId,
602
+ toolName: tool.toolName,
603
+ });
604
+ });
605
+
606
+ return entries
607
+ .map((entry) => ({
608
+ ...entry,
609
+ content: entry.content.map((block) => {
610
+ if (block.type !== "toolCall" || typeof block.id !== "string") {
611
+ return block;
612
+ }
613
+ const result = results.get(block.id);
614
+ const live = liveTools.get(block.id);
615
+ return {
616
+ ...block,
617
+ tool: {
618
+ result,
619
+ status: result
620
+ ? result.isError
621
+ ? "error"
622
+ : "success"
623
+ : (live?.status ?? "pending"),
624
+ },
625
+ };
626
+ }),
627
+ }))
628
+ .filter(
629
+ (entry) =>
630
+ entry.role !== "toolResult" ||
631
+ !entry.toolCallId ||
632
+ !callIds.has(entry.toolCallId),
633
+ );
634
+ };
635
+
636
+ const recordsSafeToProject = (stream: StreamState) => {
637
+ const records = [...stream.records.values()].sort(
638
+ (left, right) => left.sequence - right.sequence,
639
+ );
640
+ const gapIndex = records.findIndex(
641
+ (record, index) => record.sequence !== index + 1,
642
+ );
643
+ if (gapIndex < 0) {
644
+ return records;
645
+ }
646
+ return [
647
+ ...records.slice(0, gapIndex),
648
+ ...records
649
+ .slice(gapIndex)
650
+ .filter((record) => record.event.type === "message_end"),
651
+ ];
652
+ };
653
+
654
+ const sortedStreamRecords = (streams: Map<string, StreamState>) =>
655
+ [...streams.values()]
656
+ .flatMap(recordsSafeToProject)
657
+ .sort(
658
+ (left, right) =>
659
+ left.timestamp - right.timestamp ||
660
+ left.streamId.localeCompare(right.streamId) ||
661
+ left.sequence - right.sequence,
662
+ );
663
+
664
+ const streamHasIssue = (stream: StreamState) => {
665
+ if (stream.conflict) {
666
+ return true;
667
+ }
668
+ const sequences = [...stream.records.keys()].sort(
669
+ (left, right) => left - right,
670
+ );
671
+ return sequences.some((sequence, index) => sequence !== index + 1);
672
+ };
673
+
674
+ const refreshCanonical = async (
675
+ state: ProjectionState,
676
+ sessionFile: string | undefined,
677
+ ) => {
678
+ if (!sessionFile) {
679
+ const changed = state.canonicalRecords.length > 0;
680
+ state.canonicalRecords = [];
681
+ return changed;
682
+ }
683
+ const update = await state.canonicalTail.read(sessionFile);
684
+ if (update.reset) {
685
+ state.canonicalRecords = [];
686
+ }
687
+ state.canonicalRecords.push(...update.records);
688
+ return update.reset || update.records.length > 0;
689
+ };
690
+
691
+ const refreshStreams = async (
692
+ state: ProjectionState,
693
+ data: PiAgentResource,
694
+ ) => {
695
+ if (!data.liveEventsDir) {
696
+ const changed = state.streams.size > 0;
697
+ state.streams.clear();
698
+ return changed;
699
+ }
700
+ const directory = join(data.liveEventsDir, data.sessionId);
701
+ const files = (await readdir(directory).catch(() => [] as string[])).filter(
702
+ (file) => file.endsWith(".jsonl"),
703
+ );
704
+ const present = new Set(files);
705
+ let removed = false;
706
+ [...state.streams.keys()].forEach((file) => {
707
+ if (!present.has(file)) {
708
+ const stream = state.streams.get(file);
709
+ removed = removed || Boolean(stream?.conflict || stream?.records.size);
710
+ state.streams.delete(file);
711
+ }
712
+ });
713
+ const updates = await Promise.all(
714
+ files.map(async (file) => {
715
+ const stream = state.streams.get(file) ?? {
716
+ conflict: false,
717
+ records: new Map<number, LiveRecord>(),
718
+ tail: createJsonlTail(),
719
+ };
720
+ state.streams.set(file, stream);
721
+ const update = await stream.tail.read(join(directory, file));
722
+ let changed =
723
+ update.reset && Boolean(stream.conflict || stream.records.size);
724
+ if (update.reset) {
725
+ stream.conflict = false;
726
+ stream.records.clear();
727
+ }
728
+ const expectedStreamId = file.slice(0, -".jsonl".length);
729
+ update.records.forEach((value) => {
730
+ const record = parseLiveEventRecord(value, data.sessionId);
731
+ if (!record) {
732
+ return;
733
+ }
734
+ if (record.streamId !== expectedStreamId) {
735
+ changed = changed || !stream.conflict;
736
+ stream.conflict = true;
737
+ return;
738
+ }
739
+ const previous = stream.records.get(record.sequence);
740
+ if (previous && JSON.stringify(previous) !== JSON.stringify(record)) {
741
+ changed = changed || !stream.conflict;
742
+ stream.conflict = true;
743
+ return;
744
+ }
745
+ changed = changed || !previous;
746
+ stream.records.set(record.sequence, record);
747
+ });
748
+ return changed;
749
+ }),
750
+ );
751
+ return removed || updates.some(Boolean);
752
+ };
753
+
754
+ const activeCanonicalBranch = (records: unknown[]) => {
755
+ const treeEntries = records.flatMap((record) => {
756
+ if (
757
+ !isObject(record) ||
758
+ typeof record.id !== "string" ||
759
+ (record.parentId !== null && typeof record.parentId !== "string")
760
+ ) {
761
+ return [];
762
+ }
763
+ return [record as Record<string, unknown> & { id: string }];
764
+ });
765
+ const leaf = treeEntries.at(-1);
766
+ if (!leaf) {
767
+ return records;
768
+ }
769
+ const byId = new Map(treeEntries.map((entry) => [entry.id, entry]));
770
+ const ancestry = new Set<string>();
771
+ for (let current: (typeof treeEntries)[number] | undefined = leaf; current;) {
772
+ if (ancestry.has(current.id)) {
773
+ return [];
774
+ }
775
+ ancestry.add(current.id);
776
+ current =
777
+ typeof current.parentId === "string"
778
+ ? byId.get(current.parentId)
779
+ : undefined;
780
+ }
781
+ return records.filter(
782
+ (record) =>
783
+ isObject(record) &&
784
+ typeof record.id === "string" &&
785
+ ancestry.has(record.id),
786
+ );
787
+ };
788
+
789
+ const projectSnapshot = (
790
+ state: ProjectionState,
791
+ agentAvailable: boolean,
792
+ ): PiConversationSnapshot => {
793
+ const canonical = activeCanonicalBranch(state.canonicalRecords).flatMap(
794
+ (value) => {
795
+ const entry = entryOf(value, "canonical");
796
+ return entry ? [entry] : [];
797
+ },
798
+ );
799
+ const live = projectLive(sortedStreamRecords(state.streams));
800
+ const matched = matchedLiveIndexes(canonical, live.entries);
801
+ const entries = pairTools(
802
+ [
803
+ ...canonical,
804
+ ...live.entries.filter((_entry, index) => !matched.has(index)),
805
+ ].sort(
806
+ (left, right) =>
807
+ (left.timestamp ?? 0) - (right.timestamp ?? 0) ||
808
+ left.id.localeCompare(right.id),
809
+ ),
810
+ live.tools,
811
+ );
812
+ const degraded = [...state.streams.values()].some(streamHasIssue);
813
+ return piConversationSnapshotSchema.parse({
814
+ entries,
815
+ agentAvailable,
816
+ ...(state.sessionMetadata
817
+ ? { sessionMetadata: state.sessionMetadata }
818
+ : {}),
819
+ status: degraded
820
+ ? "degraded"
821
+ : live.shutdown || (!agentAvailable && !live.active)
822
+ ? "offline"
823
+ : live.active || entries.some((entry) => entry.status === "pending")
824
+ ? "busy"
825
+ : "idle",
826
+ });
827
+ };
828
+
829
+ const configKeyOf = (data: PiAgentResource) =>
830
+ JSON.stringify([data.sessionId, data.sessionFile, data.liveEventsDir]);
831
+
832
+ const createProjectionState = (data: PiAgentResource): ProjectionState => ({
833
+ canonicalRecords: [],
834
+ canonicalTail: createJsonlTail(),
835
+ configKey: configKeyOf(data),
836
+ refreshQueue: Promise.resolve(),
837
+ sessionMetadata: data.sessionMetadata,
838
+ streams: new Map(),
839
+ });
840
+
841
+ const queueProjectionRefresh = <Result>(
842
+ state: ProjectionState,
843
+ operation: () => Promise<Result>,
844
+ ) => {
845
+ const result = state.refreshQueue.then(operation);
846
+ state.refreshQueue = result.then(
847
+ () => undefined,
848
+ () => undefined,
849
+ );
850
+ return result;
851
+ };
852
+
853
+ export const probePiAgent = probePiSession;
854
+
855
+ export const createPiAgentConversationService = ({
856
+ resolveAgent,
857
+ pollIntervalMs = 250,
858
+ probe = probePiAgent,
859
+ }: {
860
+ resolveAgent: (agentId: string) => Promise<PiAgentResource>;
861
+ pollIntervalMs?: number;
862
+ probe?: (sessionId: string) => Promise<boolean>;
863
+ }): PiAgentConversationService => {
864
+ const projections = new Map<string, ProjectionState>();
865
+ const subscriptions = new Map<string, Set<SnapshotListener>>();
866
+ let timer: ReturnType<typeof setInterval> | undefined;
867
+ let polling = false;
868
+
869
+ const refresh = async (agentId: string) => {
870
+ const data = await resolveAgent(agentId);
871
+ const key = agentId;
872
+ const previous = projections.get(key);
873
+ const state =
874
+ previous?.configKey === configKeyOf(data)
875
+ ? previous
876
+ : createProjectionState(data);
877
+ projections.set(key, state);
878
+ return queueProjectionRefresh(state, async () => {
879
+ const metadataChanged =
880
+ JSON.stringify(state.sessionMetadata) !==
881
+ JSON.stringify(data.sessionMetadata);
882
+ state.sessionMetadata = data.sessionMetadata;
883
+ const [canonicalChanged, streamsChanged, agentAvailable] =
884
+ await Promise.all([
885
+ refreshCanonical(state, data.sessionFile),
886
+ refreshStreams(state, data),
887
+ probe(data.sessionId),
888
+ ]);
889
+ if (
890
+ state.snapshot &&
891
+ !canonicalChanged &&
892
+ !streamsChanged &&
893
+ !metadataChanged &&
894
+ state.agentAvailable === agentAvailable
895
+ ) {
896
+ return state.snapshot;
897
+ }
898
+ const snapshot = projectSnapshot(state, agentAvailable);
899
+ state.agentAvailable = agentAvailable;
900
+ state.snapshot = snapshot;
901
+ return snapshot;
902
+ });
903
+ };
904
+
905
+ const poll = async () => {
906
+ if (polling) {
907
+ return;
908
+ }
909
+ polling = true;
910
+ try {
911
+ await Promise.all(
912
+ [...subscriptions.entries()].map(async ([key, listeners]) => {
913
+ const id = key;
914
+ try {
915
+ const state = projections.get(key);
916
+ const before = state?.snapshot;
917
+ const snapshot = await refresh(id);
918
+ if (snapshot !== before) {
919
+ listeners.forEach((listener) => listener(snapshot));
920
+ }
921
+ } catch {
922
+ const state = projections.get(key);
923
+ if (!state || state.agentAvailable === false) {
924
+ return;
925
+ }
926
+ const snapshot = projectSnapshot(state, false);
927
+ state.agentAvailable = false;
928
+ state.snapshot = snapshot;
929
+ listeners.forEach((listener) => listener(snapshot));
930
+ }
931
+ }),
932
+ );
933
+ } finally {
934
+ polling = false;
935
+ }
936
+ };
937
+
938
+ const startPolling = () => {
939
+ if (!timer) {
940
+ timer = setInterval(() => void poll(), pollIntervalMs);
941
+ }
942
+ };
943
+
944
+ return {
945
+ dispose: () => {
946
+ if (timer) {
947
+ clearInterval(timer);
948
+ }
949
+ timer = undefined;
950
+ subscriptions.clear();
951
+ projections.clear();
952
+ },
953
+ getSnapshot: (agentId) => refresh(agentId),
954
+ subscribe: (agentId, listener) => {
955
+ const key = agentId;
956
+ const listeners = subscriptions.get(key) ?? new Set<SnapshotListener>();
957
+ listeners.add(listener);
958
+ subscriptions.set(key, listeners);
959
+ startPolling();
960
+ return () => {
961
+ listeners.delete(listener);
962
+ if (!listeners.size) {
963
+ subscriptions.delete(key);
964
+ }
965
+ if (!subscriptions.size && timer) {
966
+ clearInterval(timer);
967
+ timer = undefined;
968
+ }
969
+ };
970
+ },
971
+ };
972
+ };
973
+
974
+ export const sendPiAgentMessage = async (
975
+ {
976
+ deliverAs,
977
+ message,
978
+ sessionId,
979
+ }: {
980
+ deliverAs: "followUp" | "steer";
981
+ message: string;
982
+ sessionId: string;
983
+ },
984
+ send: PiMessageSender = sendUserMessage,
985
+ ): Promise<PiMessageResponse> => {
986
+ const response = await send(sessionId, {
987
+ deliverAs,
988
+ message,
989
+ requestId: randomUUID(),
990
+ });
991
+ if (!response?.ok) {
992
+ throw new Error("Pi agent is unavailable");
993
+ }
994
+ return response;
995
+ };