@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,578 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFile, mkdir } from "node:fs/promises";
3
+ import { hostname } from "node:os";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+
6
+ import {
7
+ createPiSessionStatusWriter,
8
+ type PiSessionStatusUpdate,
9
+ type PiSessionStatusWriter,
10
+ } from "./session-status.js";
11
+
12
+ import type {
13
+ AgentEndEvent,
14
+ AgentSettledEvent,
15
+ AgentStartEvent,
16
+ ExtensionAPI,
17
+ ExtensionContext,
18
+ MessageEndEvent,
19
+ MessageStartEvent,
20
+ MessageUpdateEvent,
21
+ SessionCompactEvent,
22
+ SessionInfoChangedEvent,
23
+ SessionShutdownEvent,
24
+ SessionStartEvent,
25
+ SessionTreeEvent,
26
+ ToolExecutionEndEvent,
27
+ ToolExecutionStartEvent,
28
+ TurnEndEvent,
29
+ TurnStartEvent,
30
+ } from "@earendil-works/pi-coding-agent";
31
+
32
+ type AssistantMessageEvent = MessageUpdateEvent["assistantMessageEvent"];
33
+ type StopReason = Extract<
34
+ AssistantMessageEvent,
35
+ { type: "done" | "error" }
36
+ >["reason"];
37
+ type ModelIdentity = { provider: string; id: string; name: string };
38
+ type LiveModelSelectEvent = {
39
+ type: "model_select";
40
+ source: "set" | "cycle" | "restore";
41
+ model: ModelIdentity;
42
+ previousModel?: ModelIdentity;
43
+ };
44
+ type LiveThinkingLevelSelectEvent = {
45
+ type: "thinking_level_select";
46
+ level: string;
47
+ previousLevel: string;
48
+ };
49
+
50
+ export type CompactAssistantMessageEvent =
51
+ | { type: "start" }
52
+ | {
53
+ type:
54
+ | "text_start"
55
+ | "text_end"
56
+ | "thinking_start"
57
+ | "thinking_end"
58
+ | "toolcall_start"
59
+ | "toolcall_end";
60
+ contentIndex: number;
61
+ }
62
+ | {
63
+ type: "text_delta" | "thinking_delta" | "toolcall_delta";
64
+ contentIndex: number;
65
+ delta: string;
66
+ }
67
+ | { type: "done"; reason: StopReason }
68
+ | { type: "error"; reason: StopReason; errorMessage?: string };
69
+
70
+ export type LiveEventPayload =
71
+ | (SessionStartEvent & { cwd: string; sessionFile?: string; pid: number })
72
+ | SessionShutdownEvent
73
+ | SessionInfoChangedEvent
74
+ | SessionCompactEvent
75
+ | SessionTreeEvent
76
+ | AgentStartEvent
77
+ | { type: AgentEndEvent["type"] }
78
+ | AgentSettledEvent
79
+ | TurnStartEvent
80
+ | { type: TurnEndEvent["type"]; turnIndex: number }
81
+ | {
82
+ type: MessageStartEvent["type"];
83
+ messageId: string;
84
+ messageSequence: number;
85
+ message: MessageStartEvent["message"];
86
+ }
87
+ | {
88
+ type: MessageUpdateEvent["type"];
89
+ messageId: string;
90
+ messageSequence: number;
91
+ assistantMessageEvent: CompactAssistantMessageEvent;
92
+ }
93
+ | {
94
+ type: MessageEndEvent["type"];
95
+ messageId: string;
96
+ messageSequence: number;
97
+ message: MessageEndEvent["message"];
98
+ }
99
+ | ToolExecutionStartEvent
100
+ | ToolExecutionEndEvent
101
+ | LiveModelSelectEvent
102
+ | LiveThinkingLevelSelectEvent;
103
+
104
+ export type LiveEventEnvelope<Event> = {
105
+ version: 1;
106
+ sessionId: string;
107
+ processInstanceId: string;
108
+ streamId: string;
109
+ sequence: number;
110
+ timestamp: number;
111
+ event: Event;
112
+ };
113
+
114
+ export type LiveEventRecord = LiveEventEnvelope<LiveEventPayload>;
115
+ export type LiveEventWireRecord = LiveEventEnvelope<
116
+ Record<string, unknown> & { type: string }
117
+ >;
118
+
119
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
120
+ typeof value === "object" && value !== null && !Array.isArray(value);
121
+
122
+ export const parseLiveEventRecord = (
123
+ value: unknown,
124
+ sessionId?: string,
125
+ ): LiveEventWireRecord | undefined => {
126
+ if (!isRecord(value) || value.version !== 1) {
127
+ return undefined;
128
+ }
129
+ if (sessionId !== undefined && value.sessionId !== sessionId) {
130
+ return undefined;
131
+ }
132
+ if (
133
+ typeof value.sessionId !== "string" ||
134
+ typeof value.processInstanceId !== "string" ||
135
+ typeof value.streamId !== "string" ||
136
+ !Number.isSafeInteger(value.sequence) ||
137
+ (value.sequence as number) < 1 ||
138
+ typeof value.timestamp !== "number" ||
139
+ !isRecord(value.event) ||
140
+ typeof value.event.type !== "string"
141
+ ) {
142
+ return undefined;
143
+ }
144
+ return value as LiveEventWireRecord;
145
+ };
146
+
147
+ export type LiveEventWriter = {
148
+ filePath: string;
149
+ streamId: string;
150
+ append: (event: LiveEventPayload) => void;
151
+ close: (event: SessionShutdownEvent) => Promise<void>;
152
+ };
153
+
154
+ type LiveEventWriterState = {
155
+ filePath: string;
156
+ sessionDir: string;
157
+ sessionId: string;
158
+ processInstanceId: string;
159
+ streamId: string;
160
+ sequence: number;
161
+ accepting: boolean;
162
+ directoryReady: boolean;
163
+ failed: boolean;
164
+ writeChain: Promise<void>;
165
+ onError?: (error: unknown) => void;
166
+ };
167
+
168
+ type LiveEventCapture = {
169
+ start: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
170
+ stop: (event: SessionShutdownEvent) => Promise<void>;
171
+ };
172
+
173
+ type LiveEventCaptureState = {
174
+ writer?: LiveEventWriter;
175
+ statusWriter?: PiSessionStatusWriter;
176
+ messageSequence: number;
177
+ activeMessage?: { id: string; sequence: number; role: string };
178
+ };
179
+
180
+ const PROCESS_INSTANCE_KEY = Symbol.for(
181
+ "overmux-pi.live-events.process-instance-id",
182
+ );
183
+ const processGlobals = globalThis as typeof globalThis & {
184
+ [PROCESS_INSTANCE_KEY]?: string;
185
+ };
186
+ const existingProcessInstanceId = processGlobals[PROCESS_INSTANCE_KEY];
187
+ export const PROCESS_INSTANCE_ID =
188
+ existingProcessInstanceId ?? `${process.pid}-${randomUUID()}`;
189
+ processGlobals[PROCESS_INSTANCE_KEY] = PROCESS_INSTANCE_ID;
190
+
191
+ const SAFE_PATH_SEGMENT = /^[A-Za-z0-9._-]{1,128}$/;
192
+
193
+ const requirePathSegment = (value: string, label: string): string => {
194
+ if (!SAFE_PATH_SEGMENT.test(value)) {
195
+ throw new Error(`Invalid ${label} for live event path`);
196
+ }
197
+ return value;
198
+ };
199
+
200
+ export const liveEventSessionDir = (
201
+ rootDir: string,
202
+ sessionId: string,
203
+ ): string =>
204
+ join(resolve(rootDir), requirePathSegment(sessionId, "session ID"));
205
+
206
+ export const liveEventStreamPath = (
207
+ rootDir: string,
208
+ sessionId: string,
209
+ streamId: string,
210
+ ): string =>
211
+ join(
212
+ liveEventSessionDir(rootDir, sessionId),
213
+ `${requirePathSegment(streamId, "stream ID")}.jsonl`,
214
+ );
215
+
216
+ const isSameOrWithin = (parent: string, candidate: string): boolean => {
217
+ const relativePath = relative(resolve(parent), resolve(candidate));
218
+ return (
219
+ relativePath === "" ||
220
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`))
221
+ );
222
+ };
223
+
224
+ const assertSeparateFromSessionFile = (
225
+ rootDir: string,
226
+ sessionFile: string | undefined,
227
+ ): void => {
228
+ if (!sessionFile) {
229
+ return;
230
+ }
231
+ const sessionDir = dirname(sessionFile);
232
+ if (
233
+ isSameOrWithin(rootDir, sessionDir) ||
234
+ isSameOrWithin(sessionDir, rootDir)
235
+ ) {
236
+ throw new Error(
237
+ "Live events directory must be outside the Pi session directory tree",
238
+ );
239
+ }
240
+ };
241
+
242
+ const reportError = (
243
+ onError: ((error: unknown) => void) | undefined,
244
+ error: unknown,
245
+ ): void => {
246
+ try {
247
+ onError?.(error);
248
+ } catch {}
249
+ };
250
+
251
+ const reportWriterError = (
252
+ state: LiveEventWriterState,
253
+ error: unknown,
254
+ ): void => {
255
+ state.failed = true;
256
+ reportError(state.onError, error);
257
+ };
258
+
259
+ const appendLiveEvent = (
260
+ state: LiveEventWriterState,
261
+ event: LiveEventPayload,
262
+ ): void => {
263
+ if (!state.accepting || state.failed) {
264
+ return;
265
+ }
266
+
267
+ const record: LiveEventRecord = {
268
+ version: 1,
269
+ sessionId: state.sessionId,
270
+ processInstanceId: state.processInstanceId,
271
+ streamId: state.streamId,
272
+ sequence: state.sequence,
273
+ timestamp: Date.now(),
274
+ event,
275
+ };
276
+ state.sequence += 1;
277
+
278
+ let line: string;
279
+ try {
280
+ line = `${JSON.stringify(record)}\n`;
281
+ } catch (error) {
282
+ reportWriterError(state, error);
283
+ return;
284
+ }
285
+
286
+ state.writeChain = state.writeChain
287
+ .then(async () => {
288
+ if (!state.directoryReady) {
289
+ await mkdir(state.sessionDir, { recursive: true, mode: 0o700 });
290
+ state.directoryReady = true;
291
+ }
292
+ await appendFile(state.filePath, line, { encoding: "utf8", mode: 0o600 });
293
+ })
294
+ .catch((error: unknown) => reportWriterError(state, error));
295
+ };
296
+
297
+ const closeLiveEventWriter = async (
298
+ state: LiveEventWriterState,
299
+ event: SessionShutdownEvent,
300
+ ): Promise<void> => {
301
+ if (!state.accepting) {
302
+ await state.writeChain;
303
+ return;
304
+ }
305
+
306
+ appendLiveEvent(state, event);
307
+ state.accepting = false;
308
+ await state.writeChain;
309
+ };
310
+
311
+ export const createLiveEventWriter = ({
312
+ rootDir,
313
+ sessionId,
314
+ processInstanceId = PROCESS_INSTANCE_ID,
315
+ streamId = randomUUID(),
316
+ onError,
317
+ }: {
318
+ rootDir: string;
319
+ sessionId: string;
320
+ processInstanceId?: string;
321
+ streamId?: string;
322
+ onError?: (error: unknown) => void;
323
+ }): LiveEventWriter => {
324
+ const state: LiveEventWriterState = {
325
+ filePath: liveEventStreamPath(rootDir, sessionId, streamId),
326
+ sessionDir: liveEventSessionDir(rootDir, sessionId),
327
+ sessionId,
328
+ processInstanceId,
329
+ streamId,
330
+ sequence: 1,
331
+ accepting: true,
332
+ directoryReady: false,
333
+ failed: false,
334
+ writeChain: Promise.resolve(),
335
+ onError,
336
+ };
337
+
338
+ return {
339
+ filePath: state.filePath,
340
+ streamId: state.streamId,
341
+ append: (event) => appendLiveEvent(state, event),
342
+ close: (event) => closeLiveEventWriter(state, event),
343
+ };
344
+ };
345
+
346
+ export const compactAssistantMessageEvent = (
347
+ event: AssistantMessageEvent,
348
+ ): CompactAssistantMessageEvent => {
349
+ if (event.type === "start") {
350
+ return { type: event.type };
351
+ }
352
+ if (event.type === "done") {
353
+ return { type: event.type, reason: event.reason };
354
+ }
355
+ if (event.type === "error") {
356
+ const errorMessage = event.error.errorMessage;
357
+ return {
358
+ type: event.type,
359
+ reason: event.reason,
360
+ ...(errorMessage ? { errorMessage } : {}),
361
+ };
362
+ }
363
+ if (
364
+ event.type === "text_delta" ||
365
+ event.type === "thinking_delta" ||
366
+ event.type === "toolcall_delta"
367
+ ) {
368
+ return {
369
+ type: event.type,
370
+ contentIndex: event.contentIndex,
371
+ delta: event.delta,
372
+ };
373
+ }
374
+ return { type: event.type, contentIndex: event.contentIndex };
375
+ };
376
+
377
+ const modelIdentity = (model: ModelIdentity): ModelIdentity => ({
378
+ provider: model.provider,
379
+ id: model.id,
380
+ name: model.name,
381
+ });
382
+
383
+ const modelOptions = (ctx: ExtensionContext): ModelIdentity[] => {
384
+ const scopedModels = ctx.scopedModels ?? [];
385
+ const availableModels = ctx.modelRegistry?.getAvailable?.() ?? [];
386
+ const models = scopedModels.length
387
+ ? scopedModels.map(({ model }) => model)
388
+ : availableModels.length
389
+ ? availableModels
390
+ : (ctx.modelRegistry?.getAll?.() ?? []);
391
+ return [
392
+ ...new Map(
393
+ models.map((model) => {
394
+ const identity = modelIdentity(model);
395
+ return [`${identity.provider}\0${identity.id}`, identity];
396
+ }),
397
+ ).values(),
398
+ ].slice(0, 256);
399
+ };
400
+
401
+ const statusContextUsage = (ctx: ExtensionContext) => {
402
+ const usage = ctx.getContextUsage();
403
+ return usage && usage.tokens !== null && usage.percent !== null
404
+ ? { ...usage, tokens: usage.tokens, percent: usage.percent }
405
+ : undefined;
406
+ };
407
+
408
+ const messageRole = (event: MessageStartEvent | MessageEndEvent): string =>
409
+ event.message.role;
410
+
411
+ export const registerLiveEventCapture = (
412
+ pi: ExtensionAPI,
413
+ options: {
414
+ rootDir: string | null;
415
+ env: NodeJS.ProcessEnv;
416
+ onError?: (error: unknown) => void;
417
+ },
418
+ ): LiveEventCapture => {
419
+ const state: LiveEventCaptureState = { messageSequence: 0 };
420
+ const append = (event: LiveEventPayload) => state.writer?.append(event);
421
+ const updateStatus = (ctx: ExtensionContext, update: PiSessionStatusUpdate) =>
422
+ state.statusWriter?.update({
423
+ contextUsage: statusContextUsage(ctx),
424
+ ...update,
425
+ });
426
+
427
+ pi.on("session_info_changed", (event) => append(event));
428
+ pi.on("session_compact", (event) => append(event));
429
+ pi.on("session_tree", (event) => append(event));
430
+ pi.on("agent_start", (event, ctx) => {
431
+ append(event);
432
+ return updateStatus(ctx, { state: "busy" });
433
+ });
434
+ pi.on("agent_end", (event) => append({ type: event.type }));
435
+ pi.on("agent_settled", (event, ctx) => {
436
+ append(event);
437
+ return updateStatus(ctx, { state: "idle" });
438
+ });
439
+ pi.on("turn_start", (event) => append(event));
440
+ pi.on("turn_end", (event, ctx) => {
441
+ append({ type: event.type, turnIndex: event.turnIndex });
442
+ return updateStatus(ctx, {});
443
+ });
444
+ pi.on("message_start", (event) => {
445
+ state.messageSequence += 1;
446
+ const activeMessage = {
447
+ id: `${state.writer?.streamId ?? "inactive"}:${state.messageSequence}`,
448
+ sequence: state.messageSequence,
449
+ role: messageRole(event),
450
+ };
451
+ state.activeMessage = activeMessage;
452
+ append({
453
+ type: event.type,
454
+ messageId: activeMessage.id,
455
+ messageSequence: activeMessage.sequence,
456
+ message: event.message,
457
+ });
458
+ });
459
+ pi.on("message_update", (event) => {
460
+ const activeMessage = state.activeMessage;
461
+ if (!activeMessage || activeMessage.role !== "assistant") {
462
+ return;
463
+ }
464
+ append({
465
+ type: event.type,
466
+ messageId: activeMessage.id,
467
+ messageSequence: activeMessage.sequence,
468
+ assistantMessageEvent: compactAssistantMessageEvent(
469
+ event.assistantMessageEvent,
470
+ ),
471
+ });
472
+ });
473
+ pi.on("message_end", (event, ctx) => {
474
+ const activeMessage = state.activeMessage;
475
+ if (!activeMessage) {
476
+ state.messageSequence += 1;
477
+ }
478
+ const identity = activeMessage ?? {
479
+ id: `${state.writer?.streamId ?? "inactive"}:${state.messageSequence}`,
480
+ sequence: state.messageSequence,
481
+ role: messageRole(event),
482
+ };
483
+ append({
484
+ type: event.type,
485
+ messageId: identity.id,
486
+ messageSequence: identity.sequence,
487
+ message: event.message,
488
+ });
489
+ state.activeMessage = undefined;
490
+ return updateStatus(ctx, {});
491
+ });
492
+ pi.on("tool_execution_start", (event) => append(event));
493
+ pi.on("tool_execution_end", (event) => append(event));
494
+ pi.on("model_select", (event, ctx) => {
495
+ const model = modelIdentity(event.model);
496
+ append({
497
+ type: event.type,
498
+ source: event.source,
499
+ model,
500
+ ...(event.previousModel
501
+ ? { previousModel: modelIdentity(event.previousModel) }
502
+ : {}),
503
+ });
504
+ return updateStatus(ctx, { model });
505
+ });
506
+ pi.on("thinking_level_select", (event, ctx) => {
507
+ append(event);
508
+ return updateStatus(ctx, { thinkingLevel: event.level });
509
+ });
510
+
511
+ return {
512
+ start: async (event, ctx) => {
513
+ state.messageSequence = 0;
514
+ state.activeMessage = undefined;
515
+ if (!options.rootDir) {
516
+ return;
517
+ }
518
+ try {
519
+ const sessionId = ctx.sessionManager.getSessionId();
520
+ const sessionFile = ctx.sessionManager.getSessionFile();
521
+ if (!sessionFile) {
522
+ throw new Error("Pi session has no session file");
523
+ }
524
+ assertSeparateFromSessionFile(options.rootDir, sessionFile);
525
+ state.writer = createLiveEventWriter({
526
+ rootDir: options.rootDir,
527
+ sessionId,
528
+ onError: options.onError,
529
+ });
530
+ const startedAt = Date.now();
531
+ const contextUsage = statusContextUsage(ctx);
532
+ const availableModelOptions = modelOptions(ctx);
533
+ state.statusWriter = createPiSessionStatusWriter({
534
+ rootDir: options.rootDir,
535
+ onError: options.onError,
536
+ status: {
537
+ sessionId,
538
+ sessionFile,
539
+ streamId: state.writer.streamId,
540
+ processInstanceId: PROCESS_INSTANCE_ID,
541
+ pid: process.pid,
542
+ hostname: hostname(),
543
+ ...(options.env.TMUX_PANE
544
+ ? { tmuxPane: options.env.TMUX_PANE }
545
+ : {}),
546
+ startedAt,
547
+ state: "idle",
548
+ ...(ctx.model ? { model: modelIdentity(ctx.model) } : {}),
549
+ ...(availableModelOptions.length
550
+ ? { modelOptions: availableModelOptions }
551
+ : {}),
552
+ thinkingLevel: ctx.thinkingLevel ?? pi.getThinkingLevel(),
553
+ ...(contextUsage ? { contextUsage } : {}),
554
+ },
555
+ });
556
+ append({
557
+ ...event,
558
+ cwd: ctx.cwd,
559
+ sessionFile,
560
+ pid: process.pid,
561
+ });
562
+ await state.statusWriter.update({});
563
+ } catch (error) {
564
+ state.writer = undefined;
565
+ state.statusWriter = undefined;
566
+ reportError(options.onError, error);
567
+ }
568
+ },
569
+ stop: async (event) => {
570
+ const writer = state.writer;
571
+ const statusWriter = state.statusWriter;
572
+ state.writer = undefined;
573
+ state.statusWriter = undefined;
574
+ state.activeMessage = undefined;
575
+ await Promise.all([writer?.close(event), statusWriter?.close()]);
576
+ },
577
+ };
578
+ };
@@ -0,0 +1,109 @@
1
+ import {
2
+ formatSize,
3
+ truncateTail,
4
+ type ExtensionAPI,
5
+ type MessageRenderer,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+
9
+ import type { OvermuxPiConfig } from "./config.js";
10
+ import type { DelegateSettledEnvelope } from "./protocol.js";
11
+
12
+ export const OVERMUX_PI_DELEGATE_SETTLED_MESSAGE_TYPE =
13
+ "overmux-pi.delegate-settled";
14
+
15
+ type DelegateNotificationDetails = DelegateSettledEnvelope & {
16
+ output: string;
17
+ };
18
+
19
+ const inspectionCommand = (
20
+ config: OvermuxPiConfig,
21
+ sessionId: string,
22
+ ): string[] =>
23
+ config.inspectionCommand.map((argument) =>
24
+ argument.replaceAll("{{childSessionId}}", sessionId),
25
+ );
26
+
27
+ const inspectionCommandLabel = (
28
+ config: OvermuxPiConfig,
29
+ sessionId: string,
30
+ ): string =>
31
+ inspectionCommand(config, sessionId)
32
+ .map((argument) => JSON.stringify(argument))
33
+ .join(" ");
34
+
35
+ export const truncatePiJqOutput = (
36
+ output: string,
37
+ sessionId: string,
38
+ config: OvermuxPiConfig,
39
+ ): string => {
40
+ const truncation = truncateTail(output);
41
+ const content = truncation.content.trimEnd() || "(no output)";
42
+ if (!truncation.truncated) {
43
+ return content;
44
+ }
45
+
46
+ return `${content}\n\n[Output truncated: showing last ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output: ${inspectionCommandLabel(config, sessionId)}]`;
47
+ };
48
+
49
+ export const inspectDelegate = async (
50
+ pi: ExtensionAPI,
51
+ envelope: DelegateSettledEnvelope,
52
+ signal: AbortSignal,
53
+ config: OvermuxPiConfig,
54
+ ): Promise<DelegateNotificationDetails> => {
55
+ try {
56
+ const [command, ...args] = inspectionCommand(
57
+ config,
58
+ envelope.childSessionId,
59
+ );
60
+ const result = await pi.exec(command!, args, {
61
+ signal,
62
+ timeout: config.inspectionTimeoutMs,
63
+ });
64
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
65
+ if (result.code === 0 && !result.killed) {
66
+ return {
67
+ ...envelope,
68
+ output: truncatePiJqOutput(output, envelope.childSessionId, config),
69
+ };
70
+ }
71
+ const reason = result.killed
72
+ ? "inspection command timed out"
73
+ : `inspection command exited with code ${result.code}`;
74
+ const details = truncatePiJqOutput(output, envelope.childSessionId, config);
75
+ return {
76
+ ...envelope,
77
+ output: `${reason}${output ? `:\n\n${details}` : "."}\n\nInspect manually: ${inspectionCommandLabel(config, envelope.childSessionId)}`,
78
+ };
79
+ } catch (error) {
80
+ const reason = error instanceof Error ? error.message : String(error);
81
+ return {
82
+ ...envelope,
83
+ output: `Could not run inspection command: ${reason}\n\nInspect manually: ${inspectionCommandLabel(config, envelope.childSessionId)}`,
84
+ };
85
+ }
86
+ };
87
+
88
+ export const notificationMessage = (
89
+ envelope: DelegateSettledEnvelope,
90
+ details: DelegateNotificationDetails,
91
+ config: OvermuxPiConfig,
92
+ ) => {
93
+ return {
94
+ customType: OVERMUX_PI_DELEGATE_SETTLED_MESSAGE_TYPE,
95
+ content: `Delegate ${envelope.taskSlug} (${envelope.childSessionId}) finished.\n\n${details.output}\n\n${config.supervisionPrompt}`,
96
+ display: true,
97
+ details,
98
+ };
99
+ };
100
+
101
+ export const notificationRenderer: MessageRenderer<
102
+ DelegateNotificationDetails
103
+ > = (message, { outputPad }, theme) => {
104
+ const details = message.details!;
105
+ const label = `${theme.fg("muted", "[")}${theme.fg("accent", details.taskSlug)}${theme.fg("muted", " finished]")}`;
106
+ return new Text(`${label}\n\n${details.output}`, outputPad, 0);
107
+ };
108
+
109
+ export type { DelegateNotificationDetails };