pi-interactive-shell 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
2
  import { InteractiveShellOverlay } from "./overlay-component.js";
4
3
  import { ReattachOverlay } from "./reattach-overlay.js";
5
4
  import { PtyTerminalSession } from "./pty-session.js";
6
- import type { InteractiveShellResult } from "./types.js";
7
- import { sessionManager, generateSessionId, releaseSessionId } from "./session-manager.js";
8
- import type { OutputOptions, OutputResult } from "./session-manager.js";
5
+ import type { InteractiveShellResult, HandsFreeUpdate } from "./types.js";
6
+ import { sessionManager, generateSessionId } from "./session-manager.js";
9
7
  import { loadConfig } from "./config.js";
10
8
  import type { InteractiveShellConfig } from "./config.js";
11
9
  import { translateInput } from "./key-encoding.js";
@@ -13,77 +11,12 @@ import { TOOL_NAME, TOOL_LABEL, TOOL_DESCRIPTION, toolParameters, type ToolParam
13
11
  import { formatDuration, formatDurationMs } from "./types.js";
14
12
  import { HeadlessDispatchMonitor } from "./headless-monitor.js";
15
13
  import type { HeadlessCompletionInfo } from "./headless-monitor.js";
14
+ import { setupBackgroundWidget } from "./background-widget.js";
15
+ import { buildDispatchNotification, buildHandsFreeUpdateMessage, buildResultNotification, summarizeInteractiveResult } from "./notification-utils.js";
16
+ import { createSessionQueryState, getSessionOutput } from "./session-query.js";
17
+ import { InteractiveShellCoordinator } from "./runtime-coordinator.js";
16
18
 
17
- let overlayOpen = false;
18
- let agentHandledCompletion = false;
19
- const headlessMonitors = new Map<string, HeadlessDispatchMonitor>();
20
-
21
- function getHeadlessOutput(session: PtyTerminalSession, opts?: OutputOptions | boolean): OutputResult {
22
- const options = typeof opts === "boolean" ? {} : (opts ?? {});
23
- const lines = options.lines ?? 20;
24
- const maxChars = options.maxChars ?? 5 * 1024;
25
- try {
26
- const result = session.getTailLines({ lines, ansi: false, maxChars });
27
- const output = result.lines.join("\n");
28
- return {
29
- output,
30
- truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
31
- totalBytes: output.length,
32
- totalLines: result.totalLinesInBuffer,
33
- };
34
- } catch {
35
- return { output: "", truncated: false, totalBytes: 0 };
36
- }
37
- }
38
-
39
- const BRIEF_TAIL_LINES = 5;
40
-
41
- function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
42
- const parts: string[] = [];
43
- if (info.timedOut) {
44
- parts.push(`Session ${sessionId} timed out (${duration}).`);
45
- } else if (info.cancelled) {
46
- parts.push(`Session ${sessionId} completed (${duration}).`);
47
- } else if (info.exitCode === 0) {
48
- parts.push(`Session ${sessionId} completed successfully (${duration}).`);
49
- } else {
50
- parts.push(`Session ${sessionId} exited with code ${info.exitCode} (${duration}).`);
51
- }
52
- if (info.completionOutput && info.completionOutput.totalLines > 0) {
53
- parts.push(` ${info.completionOutput.totalLines} lines of output.`);
54
- }
55
- if (info.completionOutput && info.completionOutput.lines.length > 0) {
56
- const allLines = info.completionOutput.lines;
57
- let end = allLines.length;
58
- while (end > 0 && allLines[end - 1].trim() === "") end--;
59
- const tail = allLines.slice(Math.max(0, end - BRIEF_TAIL_LINES), end);
60
- if (tail.length > 0) {
61
- parts.push(`\n\n${tail.join("\n")}`);
62
- }
63
- }
64
- parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
65
- return parts.join("");
66
- }
67
-
68
- function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
69
- const parts: string[] = [];
70
- if (result.timedOut) {
71
- parts.push(`Session ${sessionId} timed out.`);
72
- } else if (result.cancelled) {
73
- parts.push(`Session ${sessionId} was killed.`);
74
- } else if (result.exitCode === 0) {
75
- parts.push(`Session ${sessionId} completed successfully.`);
76
- } else {
77
- parts.push(`Session ${sessionId} exited with code ${result.exitCode}.`);
78
- }
79
- if (result.completionOutput && result.completionOutput.lines.length > 0) {
80
- const truncNote = result.completionOutput.truncated
81
- ? ` (truncated from ${result.completionOutput.totalLines} total lines)`
82
- : "";
83
- parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
84
- }
85
- return parts.join("");
86
- }
19
+ const coordinator = new InteractiveShellCoordinator();
87
20
 
88
21
  function makeMonitorCompletionCallback(
89
22
  pi: ExtensionAPI,
@@ -101,7 +34,7 @@ function makeMonitorCompletionCallback(
101
34
  }, { triggerTurn: true });
102
35
  pi.events.emit("interactive-shell:transfer", { sessionId: id, ...info });
103
36
  sessionManager.unregisterActive(id, false);
104
- headlessMonitors.delete(id);
37
+ coordinator.deleteMonitor(id);
105
38
  sessionManager.scheduleCleanup(id, 5 * 60 * 1000);
106
39
  };
107
40
  }
@@ -113,20 +46,24 @@ function registerHeadlessActive(
113
46
  session: PtyTerminalSession,
114
47
  monitor: HeadlessDispatchMonitor,
115
48
  startTime: number,
49
+ config: InteractiveShellConfig,
116
50
  ): void {
51
+ const queryState = createSessionQueryState();
52
+ coordinator.setMonitor(id, monitor);
53
+ const getCompletionOutput = () => monitor.getResult()?.completionOutput;
54
+
117
55
  sessionManager.registerActive({
118
56
  id,
119
57
  command,
120
58
  reason,
121
59
  write: (data) => session.write(data),
122
60
  kill: () => {
123
- monitor.dispose();
61
+ coordinator.disposeMonitor(id);
124
62
  sessionManager.remove(id);
125
63
  sessionManager.unregisterActive(id, true);
126
- headlessMonitors.delete(id);
127
64
  },
128
65
  background: () => {},
129
- getOutput: (opts) => getHeadlessOutput(session, opts),
66
+ getOutput: (opts) => getSessionOutput(session, config, queryState, opts, getCompletionOutput()),
130
67
  getStatus: () => session.exited ? "exited" : "running",
131
68
  getRuntime: () => Date.now() - startTime,
132
69
  getResult: () => monitor.getResult(),
@@ -134,93 +71,32 @@ function registerHeadlessActive(
134
71
  });
135
72
  }
136
73
 
137
- let bgWidgetCleanup: (() => void) | null = null;
138
-
139
- function setupBackgroundWidget(ctx: { ui: { setWidget: Function }; hasUI?: boolean }) {
140
- if (!ctx.hasUI) return;
141
-
142
- bgWidgetCleanup?.();
143
-
144
- let durationTimer: ReturnType<typeof setInterval> | null = null;
145
- let tuiRef: { requestRender: () => void } | null = null;
146
-
147
- const requestRender = () => tuiRef?.requestRender();
148
-
149
- const unsubscribe = sessionManager.onChange(() => {
150
- manageDurationTimer();
151
- requestRender();
152
- });
153
-
154
- function manageDurationTimer() {
155
- const sessions = sessionManager.list();
156
- const hasRunning = sessions.some((s) => !s.session.exited);
157
- if (hasRunning && !durationTimer) {
158
- durationTimer = setInterval(requestRender, 10_000);
159
- } else if (!hasRunning && durationTimer) {
160
- clearInterval(durationTimer);
161
- durationTimer = null;
162
- }
163
- }
164
-
165
- ctx.ui.setWidget(
166
- "bg-sessions",
167
- (tui: any, theme: any) => {
168
- tuiRef = tui;
169
- return {
170
- render: (width: number) => {
171
- const sessions = sessionManager.list();
172
- if (sessions.length === 0) return [];
173
- const cols = width || tui.terminal?.columns || 120;
174
- const lines: string[] = [];
175
- for (const s of sessions) {
176
- const exited = s.session.exited;
177
- const dot = exited ? theme.fg("dim", "○") : theme.fg("accent", "●");
178
- const id = theme.fg("dim", s.id);
179
- const cmd = s.command.replace(/\s+/g, " ").trim();
180
- const truncCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
181
- const reason = s.reason ? theme.fg("dim", ` · ${s.reason}`) : "";
182
- const status = exited ? theme.fg("dim", "exited") : theme.fg("success", "running");
183
- const duration = theme.fg("dim", formatDuration(Date.now() - s.startedAt.getTime()));
184
- const oneLine = ` ${dot} ${id} ${truncCmd}${reason} ${status} ${duration}`;
185
- if (visibleWidth(oneLine) <= cols) {
186
- lines.push(oneLine);
187
- } else {
188
- lines.push(truncateToWidth(` ${dot} ${id} ${cmd}`, cols, "…"));
189
- lines.push(truncateToWidth(` ${status} ${duration}${reason}`, cols, "…"));
190
- }
191
- }
192
- return lines;
193
- },
194
- invalidate: () => {},
195
- };
196
- },
197
- { placement: "belowEditor" },
198
- );
199
-
200
- manageDurationTimer();
201
-
202
- bgWidgetCleanup = () => {
203
- unsubscribe();
204
- if (durationTimer) {
205
- clearInterval(durationTimer);
206
- durationTimer = null;
207
- }
208
- ctx.ui.setWidget("bg-sessions", undefined);
209
- bgWidgetCleanup = null;
74
+ function makeNonBlockingUpdateHandler(pi: ExtensionAPI): (update: HandsFreeUpdate) => void {
75
+ return (update) => {
76
+ pi.events.emit("interactive-shell:update", update);
77
+ const message = buildHandsFreeUpdateMessage(update);
78
+ if (!message) return;
79
+ pi.sendMessage({
80
+ customType: "interactive-shell-update",
81
+ content: message.content,
82
+ display: true,
83
+ details: message.details,
84
+ }, { triggerTurn: true });
210
85
  };
211
86
  }
212
87
 
213
88
  export default function interactiveShellExtension(pi: ExtensionAPI) {
214
- pi.on("session_start", (_event, ctx) => setupBackgroundWidget(ctx));
215
- pi.on("session_switch", (_event, ctx) => setupBackgroundWidget(ctx));
89
+ pi.on("session_start", (_event, ctx) => {
90
+ coordinator.replaceBackgroundWidgetCleanup(setupBackgroundWidget(ctx, sessionManager));
91
+ });
92
+ pi.on("session_switch", (_event, ctx) => {
93
+ coordinator.replaceBackgroundWidgetCleanup(setupBackgroundWidget(ctx, sessionManager));
94
+ });
216
95
 
217
96
  pi.on("session_shutdown", () => {
218
- bgWidgetCleanup?.();
97
+ coordinator.clearBackgroundWidget();
219
98
  sessionManager.killAll();
220
- for (const [id, monitor] of headlessMonitors) {
221
- monitor.dispose();
222
- headlessMonitors.delete(id);
223
- }
99
+ coordinator.disposeAllMonitors();
224
100
  });
225
101
 
226
102
  pi.registerTool({
@@ -276,9 +152,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
276
152
 
277
153
  // Kill
278
154
  if (kill) {
279
- const hMonitor = headlessMonitors.get(sessionId);
155
+ const hMonitor = coordinator.getMonitor(sessionId);
280
156
  if (!hMonitor || hMonitor.disposed) {
281
- agentHandledCompletion = true;
157
+ coordinator.markAgentHandledCompletion(sessionId);
282
158
  }
283
159
  const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
284
160
  const status = session.getStatus();
@@ -302,14 +178,14 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
302
178
  details: session.getResult(),
303
179
  };
304
180
  }
305
- const bMonitor = headlessMonitors.get(sessionId);
181
+ const bMonitor = coordinator.getMonitor(sessionId);
306
182
  if (!bMonitor || bMonitor.disposed) {
307
- agentHandledCompletion = true;
183
+ coordinator.markAgentHandledCompletion(sessionId);
308
184
  }
309
185
  session.background();
310
186
  const result = session.getResult();
311
187
  if (!result || !result.backgrounded) {
312
- agentHandledCompletion = false;
188
+ coordinator.consumeAgentHandledCompletion(sessionId);
313
189
  return {
314
190
  content: [{ type: "text", text: `Session ${sessionId} is already running in the background.` }],
315
191
  details: { sessionId },
@@ -452,7 +328,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
452
328
  isError: true,
453
329
  };
454
330
  }
455
- if (overlayOpen) {
331
+ if (coordinator.isOverlayOpen()) {
456
332
  return {
457
333
  content: [{ type: "text", text: "An interactive shell overlay is already open." }],
458
334
  isError: true,
@@ -460,6 +336,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
460
336
  };
461
337
  }
462
338
 
339
+ const monitor = coordinator.getMonitor(attach);
463
340
  const bgSession = sessionManager.take(attach);
464
341
  if (!bgSession) {
465
342
  return {
@@ -468,51 +345,74 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
468
345
  };
469
346
  }
470
347
 
348
+ const restoreAttachSession = () => {
349
+ sessionManager.restore(bgSession, { noAutoCleanup: Boolean(monitor && !monitor.disposed) });
350
+ return {
351
+ releaseId: false,
352
+ disposeMonitor: false,
353
+ };
354
+ };
355
+ if (!coordinator.beginOverlay()) {
356
+ restoreAttachSession();
357
+ return {
358
+ content: [{ type: "text", text: "An interactive shell overlay is already open." }],
359
+ isError: true,
360
+ details: { error: "overlay_already_open" },
361
+ };
362
+ }
363
+
471
364
  const config = loadConfig(cwd ?? ctx.cwd);
472
365
  const reattachSessionId = attach;
473
- const monitor = headlessMonitors.get(attach);
474
-
475
366
  const isNonBlocking = mode === "hands-free" || mode === "dispatch";
476
-
477
- overlayOpen = true;
478
- const attachStartTime = Date.now();
479
- const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
480
- (tui, theme, _kb, done) =>
481
- new InteractiveShellOverlay(tui, theme, {
482
- command: bgSession.command,
483
- existingSession: bgSession.session,
484
- sessionId: reattachSessionId,
485
- mode,
486
- cwd: cwd ?? ctx.cwd,
487
- name: bgSession.name,
488
- reason: bgSession.reason ?? reason,
489
- handsFreeUpdateMode: handsFree?.updateMode,
490
- handsFreeUpdateInterval: handsFree?.updateInterval,
491
- handsFreeQuietThreshold: handsFree?.quietThreshold,
492
- handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
493
- handsFreeMaxTotalChars: handsFree?.maxTotalChars,
494
- autoExitOnQuiet: mode === "dispatch"
495
- ? handsFree?.autoExitOnQuiet !== false
496
- : handsFree?.autoExitOnQuiet === true,
497
- autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
498
- handoffPreviewEnabled: handoffPreview?.enabled,
499
- handoffPreviewLines: handoffPreview?.lines,
500
- handoffPreviewMaxChars: handoffPreview?.maxChars,
501
- handoffSnapshotEnabled: handoffSnapshot?.enabled,
502
- handoffSnapshotLines: handoffSnapshot?.lines,
503
- handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
504
- timeout,
505
- }, config, done),
506
- {
507
- overlay: true,
508
- overlayOptions: {
509
- width: `${config.overlayWidthPercent}%`,
510
- maxHeight: `${config.overlayHeightPercent}%`,
511
- anchor: "center",
512
- margin: 1,
367
+ const attachStartTime = bgSession.startedAt.getTime();
368
+ let overlayPromise: Promise<InteractiveShellResult>;
369
+ try {
370
+ overlayPromise = ctx.ui.custom<InteractiveShellResult>(
371
+ (tui, theme, _kb, done) =>
372
+ new InteractiveShellOverlay(tui, theme, {
373
+ command: bgSession.command,
374
+ existingSession: bgSession.session,
375
+ sessionId: reattachSessionId,
376
+ mode,
377
+ cwd: cwd ?? ctx.cwd,
378
+ name: bgSession.name,
379
+ reason: bgSession.reason ?? reason,
380
+ startedAt: attachStartTime,
381
+ handsFreeUpdateMode: handsFree?.updateMode,
382
+ handsFreeUpdateInterval: handsFree?.updateInterval,
383
+ handsFreeQuietThreshold: handsFree?.quietThreshold,
384
+ handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
385
+ handsFreeMaxTotalChars: handsFree?.maxTotalChars,
386
+ autoExitOnQuiet: mode === "dispatch"
387
+ ? handsFree?.autoExitOnQuiet !== false
388
+ : handsFree?.autoExitOnQuiet === true,
389
+ autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
390
+ onHandsFreeUpdate: mode === "hands-free"
391
+ ? makeNonBlockingUpdateHandler(pi)
392
+ : undefined,
393
+ handoffPreviewEnabled: handoffPreview?.enabled,
394
+ handoffPreviewLines: handoffPreview?.lines,
395
+ handoffPreviewMaxChars: handoffPreview?.maxChars,
396
+ handoffSnapshotEnabled: handoffSnapshot?.enabled,
397
+ handoffSnapshotLines: handoffSnapshot?.lines,
398
+ handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
399
+ timeout,
400
+ }, config, done),
401
+ {
402
+ overlay: true,
403
+ overlayOptions: {
404
+ width: `${config.overlayWidthPercent}%`,
405
+ maxHeight: `${config.overlayHeightPercent}%`,
406
+ anchor: "center",
407
+ margin: 1,
408
+ },
513
409
  },
514
- },
515
- );
410
+ );
411
+ } catch (error) {
412
+ coordinator.endOverlay();
413
+ restoreAttachSession();
414
+ throw error;
415
+ }
516
416
 
517
417
  if (isNonBlocking) {
518
418
  setupDispatchCompletion(pi, overlayPromise, config, {
@@ -523,6 +423,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
523
423
  timeout,
524
424
  handsFree,
525
425
  overlayStartTime: attachStartTime,
426
+ onOverlayError: restoreAttachSession,
526
427
  });
527
428
  return {
528
429
  content: [{ type: "text", text: mode === "dispatch"
@@ -532,39 +433,27 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
532
433
  };
533
434
  }
534
435
 
535
- // Blocking (interactive) attach
536
436
  let result: InteractiveShellResult;
537
437
  try {
538
438
  result = await overlayPromise;
439
+ } catch (error) {
440
+ restoreAttachSession();
441
+ throw error;
539
442
  } finally {
540
- overlayOpen = false;
443
+ coordinator.endOverlay();
541
444
  }
542
- if (monitor) {
543
- monitor.dispose();
544
- headlessMonitors.delete(attach);
545
- sessionManager.unregisterActive(attach, !result.backgrounded);
546
- } else if (!result.backgrounded) {
547
- releaseSessionId(attach);
548
- }
549
-
550
- let summary: string;
551
- if (result.transferred) {
552
- const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
553
- summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
445
+ if (monitor && !monitor.disposed) {
446
+ if (!result.backgrounded) {
447
+ monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
448
+ coordinator.deleteMonitor(attach);
449
+ }
554
450
  } else if (result.backgrounded) {
555
- summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
556
- } else if (result.cancelled) {
557
- summary = "Session killed";
558
- } else if (result.timedOut) {
559
- summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
451
+ sessionManager.restartAutoCleanup(attach);
560
452
  } else {
561
- const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
562
- summary = `Session ended ${status}`;
563
- }
564
- if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
565
- summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
453
+ sessionManager.scheduleCleanup(attach);
566
454
  }
567
- return { content: [{ type: "text", text: summary }], details: result };
455
+
456
+ return { content: [{ type: "text", text: summarizeInteractiveResult(command ?? bgSession.command, result, timeout, bgSession.reason ?? reason) }], details: result };
568
457
  }
569
458
 
570
459
  // ── Branch 3: List background sessions ──
@@ -599,11 +488,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
599
488
  }
600
489
 
601
490
  for (const tid of targetIds) {
602
- const monitor = headlessMonitors.get(tid);
603
- if (monitor) {
604
- monitor.dispose();
605
- headlessMonitors.delete(tid);
606
- }
491
+ coordinator.disposeMonitor(tid);
607
492
  sessionManager.unregisterActive(tid, false);
608
493
  sessionManager.remove(tid);
609
494
  }
@@ -632,17 +517,18 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
632
517
  const session = new PtyTerminalSession(
633
518
  { command, cwd: effectiveCwd, cols: 120, rows: 40, scrollback: config.scrollbackLines },
634
519
  );
635
- sessionManager.add(command, session, name, reason, { id, noAutoCleanup: true });
636
520
 
637
521
  const startTime = Date.now();
522
+ sessionManager.add(command, session, name, reason, { id, noAutoCleanup: true, startedAt: new Date(startTime) });
523
+
638
524
  const monitor = new HeadlessDispatchMonitor(session, config, {
639
525
  autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
640
526
  quietThreshold: handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
641
527
  gracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
642
528
  timeout,
529
+ startedAt: startTime,
643
530
  }, makeMonitorCompletionCallback(pi, id, startTime));
644
- headlessMonitors.set(id, monitor);
645
- registerHeadlessActive(id, command, reason, session, monitor, startTime);
531
+ registerHeadlessActive(id, command, reason, session, monitor, startTime, config);
646
532
 
647
533
  return {
648
534
  content: [{ type: "text", text: `Session dispatched in background (id: ${id}).\nYou'll be notified when it completes. User can /attach ${id} to watch.` }],
@@ -665,7 +551,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
665
551
  };
666
552
  }
667
553
 
668
- if (overlayOpen) {
554
+ if (coordinator.isOverlayOpen()) {
669
555
  return {
670
556
  content: [{ type: "text", text: "An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one." }],
671
557
  isError: true,
@@ -677,45 +563,61 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
677
563
 
678
564
  // ── Non-blocking path (hands-free or dispatch) ──
679
565
  if (isNonBlocking && generatedSessionId) {
680
- overlayOpen = true;
566
+ if (!coordinator.beginOverlay()) {
567
+ return {
568
+ content: [{ type: "text", text: "An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one." }],
569
+ isError: true,
570
+ details: { error: "overlay_already_open" },
571
+ };
572
+ }
681
573
  const overlayStartTime = Date.now();
682
574
 
683
- const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
684
- (tui, theme, _kb, done) =>
685
- new InteractiveShellOverlay(tui, theme, {
686
- command,
687
- cwd: effectiveCwd,
688
- name,
689
- reason,
690
- mode,
691
- sessionId: generatedSessionId,
692
- handsFreeUpdateMode: handsFree?.updateMode,
693
- handsFreeUpdateInterval: handsFree?.updateInterval,
694
- handsFreeQuietThreshold: handsFree?.quietThreshold,
695
- handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
696
- handsFreeMaxTotalChars: handsFree?.maxTotalChars,
697
- autoExitOnQuiet: mode === "dispatch"
698
- ? handsFree?.autoExitOnQuiet !== false
699
- : handsFree?.autoExitOnQuiet === true,
700
- autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
701
- handoffPreviewEnabled: handoffPreview?.enabled,
702
- handoffPreviewLines: handoffPreview?.lines,
703
- handoffPreviewMaxChars: handoffPreview?.maxChars,
704
- handoffSnapshotEnabled: handoffSnapshot?.enabled,
705
- handoffSnapshotLines: handoffSnapshot?.lines,
706
- handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
707
- timeout,
708
- }, config, done),
709
- {
710
- overlay: true,
711
- overlayOptions: {
712
- width: `${config.overlayWidthPercent}%`,
713
- maxHeight: `${config.overlayHeightPercent}%`,
714
- anchor: "center",
715
- margin: 1,
575
+ let overlayPromise: Promise<InteractiveShellResult>;
576
+ try {
577
+ overlayPromise = ctx.ui.custom<InteractiveShellResult>(
578
+ (tui, theme, _kb, done) =>
579
+ new InteractiveShellOverlay(tui, theme, {
580
+ command,
581
+ cwd: effectiveCwd,
582
+ name,
583
+ reason,
584
+ mode,
585
+ sessionId: generatedSessionId,
586
+ startedAt: overlayStartTime,
587
+ handsFreeUpdateMode: handsFree?.updateMode,
588
+ handsFreeUpdateInterval: handsFree?.updateInterval,
589
+ handsFreeQuietThreshold: handsFree?.quietThreshold,
590
+ handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
591
+ handsFreeMaxTotalChars: handsFree?.maxTotalChars,
592
+ autoExitOnQuiet: mode === "dispatch"
593
+ ? handsFree?.autoExitOnQuiet !== false
594
+ : handsFree?.autoExitOnQuiet === true,
595
+ autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
596
+ onHandsFreeUpdate: mode === "hands-free"
597
+ ? makeNonBlockingUpdateHandler(pi)
598
+ : undefined,
599
+ handoffPreviewEnabled: handoffPreview?.enabled,
600
+ handoffPreviewLines: handoffPreview?.lines,
601
+ handoffPreviewMaxChars: handoffPreview?.maxChars,
602
+ handoffSnapshotEnabled: handoffSnapshot?.enabled,
603
+ handoffSnapshotLines: handoffSnapshot?.lines,
604
+ handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
605
+ timeout,
606
+ }, config, done),
607
+ {
608
+ overlay: true,
609
+ overlayOptions: {
610
+ width: `${config.overlayWidthPercent}%`,
611
+ maxHeight: `${config.overlayHeightPercent}%`,
612
+ anchor: "center",
613
+ margin: 1,
614
+ },
716
615
  },
717
- },
718
- );
616
+ );
617
+ } catch (error) {
618
+ coordinator.endOverlay();
619
+ throw error;
620
+ }
719
621
 
720
622
  setupDispatchCompletion(pi, overlayPromise, config, {
721
623
  id: generatedSessionId,
@@ -740,7 +642,13 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
740
642
  }
741
643
 
742
644
  // ── Blocking (interactive) path ──
743
- overlayOpen = true;
645
+ if (!coordinator.beginOverlay()) {
646
+ return {
647
+ content: [{ type: "text", text: "An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one." }],
648
+ isError: true,
649
+ details: { error: "overlay_already_open" },
650
+ };
651
+ }
744
652
  onUpdate?.({
745
653
  content: [{ type: "text", text: `Opening: ${command}` }],
746
654
  details: { exitCode: null, backgrounded: false, cancelled: false },
@@ -764,6 +672,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
764
672
  handsFreeMaxTotalChars: handsFree?.maxTotalChars,
765
673
  autoExitOnQuiet: handsFree?.autoExitOnQuiet,
766
674
  autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
675
+ streamingMode: mode === "hands-free",
767
676
  onHandsFreeUpdate: mode === "hands-free"
768
677
  ? (update) => {
769
678
  let statusText: string;
@@ -774,6 +683,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
774
683
  case "exited":
775
684
  statusText = `Session ${update.sessionId} exited`;
776
685
  break;
686
+ case "killed":
687
+ statusText = `Session ${update.sessionId} killed`;
688
+ break;
777
689
  default: {
778
690
  const budgetInfo = update.budgetExhausted ? " [budget exhausted]" : "";
779
691
  statusText = `Session ${update.sessionId} running (${formatDurationMs(update.runtime)})${budgetInfo}`;
@@ -794,6 +706,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
794
706
  userTookOver: update.userTookOver,
795
707
  },
796
708
  });
709
+ pi.events.emit("interactive-shell:update", update);
797
710
  }
798
711
  : undefined,
799
712
  handoffPreviewEnabled: handoffPreview?.enabled,
@@ -815,45 +728,17 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
815
728
  },
816
729
  );
817
730
  } finally {
818
- overlayOpen = false;
819
- }
820
-
821
- let summary: string;
822
- if (result.transferred) {
823
- const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
824
- summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
825
- } else if (result.backgrounded) {
826
- summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
827
- } else if (result.cancelled) {
828
- summary = "User killed the interactive session";
829
- } else if (result.timedOut) {
830
- summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
831
- } else {
832
- const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
833
- summary = `Session ended ${status}`;
731
+ coordinator.endOverlay();
834
732
  }
835
733
 
836
- if (result.userTookOver) {
837
- summary += "\n\nNote: User took over control during hands-free mode.";
838
- }
839
-
840
- const warning = buildIdlePromptWarning(command, reason);
841
- if (warning) {
842
- summary += `\n\n${warning}`;
843
- }
844
-
845
- if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
846
- summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
847
- }
848
-
849
- return { content: [{ type: "text", text: summary }], details: result };
734
+ return { content: [{ type: "text", text: summarizeInteractiveResult(command, result, timeout, reason) }], details: result };
850
735
  },
851
736
  });
852
737
 
853
738
  pi.registerCommand("attach", {
854
739
  description: "Reattach to a background shell session",
855
740
  handler: async (args, ctx) => {
856
- if (overlayOpen) {
741
+ if (coordinator.isOverlayOpen()) {
857
742
  ctx.ui.notify("An overlay is already open. Close it first.", "error");
858
743
  return;
859
744
  }
@@ -879,7 +764,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
879
764
  targetId = choice.split(" - ")[0]!;
880
765
  }
881
766
 
882
- const monitor = headlessMonitors.get(targetId);
767
+ const monitor = coordinator.getMonitor(targetId);
883
768
 
884
769
  const session = sessionManager.get(targetId);
885
770
  if (!session) {
@@ -888,7 +773,10 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
888
773
  }
889
774
 
890
775
  const config = loadConfig(ctx.cwd);
891
- overlayOpen = true;
776
+ if (!coordinator.beginOverlay()) {
777
+ ctx.ui.notify("An overlay is already open. Close it first.", "error");
778
+ return;
779
+ }
892
780
  try {
893
781
  const result = await ctx.ui.custom<InteractiveShellResult>(
894
782
  (tui, theme, _kb, done) =>
@@ -907,7 +795,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
907
795
  if (monitor && !monitor.disposed) {
908
796
  if (!result.backgrounded) {
909
797
  monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
910
- headlessMonitors.delete(targetId);
798
+ coordinator.deleteMonitor(targetId);
911
799
  }
912
800
  } else if (result.backgrounded) {
913
801
  sessionManager.restartAutoCleanup(targetId);
@@ -915,7 +803,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
915
803
  sessionManager.scheduleCleanup(targetId);
916
804
  }
917
805
  } finally {
918
- overlayOpen = false;
806
+ coordinator.endOverlay();
919
807
  }
920
808
  },
921
809
  });
@@ -953,11 +841,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
953
841
  }
954
842
 
955
843
  for (const tid of targetIds) {
956
- const monitor = headlessMonitors.get(tid);
957
- if (monitor) {
958
- monitor.dispose();
959
- headlessMonitors.delete(tid);
960
- }
844
+ coordinator.disposeMonitor(tid);
961
845
  sessionManager.unregisterActive(tid, false);
962
846
  sessionManager.remove(tid);
963
847
  }
@@ -980,15 +864,15 @@ function setupDispatchCompletion(
980
864
  timeout?: number;
981
865
  handsFree?: { autoExitOnQuiet?: boolean; quietThreshold?: number; gracePeriod?: number };
982
866
  overlayStartTime?: number;
867
+ onOverlayError?: () => { releaseId?: boolean; disposeMonitor?: boolean } | void;
983
868
  },
984
869
  ): void {
985
870
  const { id, mode, command, reason } = ctx;
986
871
 
987
872
  overlayPromise.then((result) => {
988
- overlayOpen = false;
873
+ coordinator.endOverlay();
989
874
 
990
- const wasAgentInitiated = agentHandledCompletion;
991
- agentHandledCompletion = false;
875
+ const wasAgentInitiated = coordinator.consumeAgentHandledCompletion(id);
992
876
 
993
877
  if (result.transferred) {
994
878
  const truncatedNote = result.transferred.truncated
@@ -1003,10 +887,11 @@ function setupDispatchCompletion(
1003
887
  }, { triggerTurn: true });
1004
888
  pi.events.emit("interactive-shell:transfer", { sessionId: id, transferred: result.transferred, exitCode: result.exitCode, signal: result.signal });
1005
889
  sessionManager.unregisterActive(id, true);
890
+ coordinator.disposeMonitor(id);
891
+ return;
892
+ }
1006
893
 
1007
- const remainingMonitor = headlessMonitors.get(id);
1008
- if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
1009
- } else if (mode === "dispatch" && result.backgrounded) {
894
+ if (mode === "dispatch" && result.backgrounded) {
1010
895
  if (!wasAgentInitiated) {
1011
896
  pi.sendMessage({
1012
897
  customType: "interactive-shell-transfer",
@@ -1017,31 +902,32 @@ function setupDispatchCompletion(
1017
902
  }
1018
903
  sessionManager.unregisterActive(id, false);
1019
904
 
1020
- const existingMonitor = headlessMonitors.get(id);
905
+ const bgId = result.backgroundId!;
906
+ const existingMonitor = coordinator.getMonitor(id);
907
+ const bgSession = sessionManager.get(bgId);
908
+ if (!bgSession) return;
909
+
1021
910
  if (existingMonitor && !existingMonitor.disposed) {
1022
- const bgSession = sessionManager.get(result.backgroundId!);
1023
- if (bgSession) {
1024
- registerHeadlessActive(result.backgroundId!, command, reason, bgSession.session, existingMonitor, existingMonitor.startTime);
1025
- }
1026
- } else if (!existingMonitor) {
1027
- const bgSession = sessionManager.get(result.backgroundId!);
1028
- if (bgSession) {
1029
- const bgId = result.backgroundId!;
1030
- const bgStartTime = ctx.overlayStartTime ?? Date.now();
1031
- const elapsed = ctx.overlayStartTime ? Date.now() - ctx.overlayStartTime : 0;
1032
- const remainingTimeout = ctx.timeout ? Math.max(0, ctx.timeout - elapsed) : undefined;
1033
-
1034
- const monitor = new HeadlessDispatchMonitor(bgSession.session, config, {
1035
- autoExitOnQuiet: ctx.handsFree?.autoExitOnQuiet !== false,
1036
- quietThreshold: ctx.handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
1037
- gracePeriod: ctx.handsFree?.gracePeriod ?? config.autoExitGracePeriod,
1038
- timeout: remainingTimeout,
1039
- }, makeMonitorCompletionCallback(pi, bgId, bgStartTime));
1040
- headlessMonitors.set(bgId, monitor);
1041
- registerHeadlessActive(bgId, command, reason, bgSession.session, monitor, bgStartTime);
1042
- }
911
+ coordinator.deleteMonitor(id);
912
+ registerHeadlessActive(bgId, command, reason, bgSession.session, existingMonitor, bgSession.startedAt.getTime(), config);
913
+ return;
1043
914
  }
1044
- } else if (mode === "dispatch") {
915
+
916
+ const elapsed = ctx.overlayStartTime ? Date.now() - ctx.overlayStartTime : 0;
917
+ const remainingTimeout = ctx.timeout ? Math.max(0, ctx.timeout - elapsed) : undefined;
918
+ const bgStartTime = bgSession.startedAt.getTime();
919
+ const monitor = new HeadlessDispatchMonitor(bgSession.session, config, {
920
+ autoExitOnQuiet: ctx.handsFree?.autoExitOnQuiet !== false,
921
+ quietThreshold: ctx.handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
922
+ gracePeriod: ctx.handsFree?.gracePeriod ?? config.autoExitGracePeriod,
923
+ timeout: remainingTimeout,
924
+ startedAt: bgStartTime,
925
+ }, makeMonitorCompletionCallback(pi, bgId, bgStartTime));
926
+ registerHeadlessActive(bgId, command, reason, bgSession.session, monitor, bgStartTime, config);
927
+ return;
928
+ }
929
+
930
+ if (mode === "dispatch") {
1045
931
  if (!wasAgentInitiated) {
1046
932
  const content = buildResultNotification(id, result);
1047
933
  pi.sendMessage({
@@ -1060,47 +946,17 @@ function setupDispatchCompletion(
1060
946
  cancelled: result.cancelled,
1061
947
  });
1062
948
  sessionManager.unregisterActive(id, true);
1063
-
1064
- const remainingMonitor = headlessMonitors.get(id);
1065
- if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
949
+ coordinator.disposeMonitor(id);
950
+ return;
1066
951
  }
1067
952
 
1068
- if (mode !== "dispatch") {
1069
- const staleMonitor = headlessMonitors.get(id);
1070
- if (staleMonitor) { staleMonitor.dispose(); headlessMonitors.delete(id); }
1071
- }
953
+ coordinator.disposeMonitor(id);
1072
954
  }).catch(() => {
1073
- overlayOpen = false;
1074
- sessionManager.unregisterActive(id, true);
1075
- const orphanedMonitor = headlessMonitors.get(id);
1076
- if (orphanedMonitor) { orphanedMonitor.dispose(); headlessMonitors.delete(id); }
955
+ coordinator.endOverlay();
956
+ const recovery = ctx.onOverlayError?.();
957
+ sessionManager.unregisterActive(id, recovery?.releaseId ?? true);
958
+ if (recovery?.disposeMonitor !== false) {
959
+ coordinator.disposeMonitor(id);
960
+ }
1077
961
  });
1078
962
  }
1079
-
1080
- function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
1081
- if (!reason) return null;
1082
-
1083
- const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
1084
- if (!tasky.test(reason)) return null;
1085
-
1086
- const trimmed = command.trim();
1087
- const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
1088
- const bin = binaries.find((b) => trimmed === b || trimmed.startsWith(`${b} `));
1089
- if (!bin) return null;
1090
-
1091
- const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
1092
- const hasQuotedPrompt = /["']/.test(rest);
1093
- const hasKnownPromptFlag =
1094
- /\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
1095
- (bin === "pi" && /\b-p\b/.test(rest)) ||
1096
- (bin === "codex" && /\bexec\b/.test(rest));
1097
-
1098
- if (hasQuotedPrompt || hasKnownPromptFlag) return null;
1099
- if (rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest)) {
1100
- const examplePrompt = reason.replace(/\s+/g, " ").trim();
1101
- const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
1102
- return `Note: \`reason\` is UI-only. This command likely started the agent idle. If you intended an initial prompt, embed it in \`command\`, e.g. \`${bin} "${clipped}"\`.`;
1103
- }
1104
-
1105
- return null;
1106
- }