pi-interactive-shell 0.9.0 → 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
5
  import type { InteractiveShellResult, HandsFreeUpdate } from "./types.js";
7
- import { sessionManager, generateSessionId, releaseSessionId } from "./session-manager.js";
8
- import type { OutputOptions, OutputResult } from "./session-manager.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(),
@@ -137,120 +74,29 @@ function registerHeadlessActive(
137
74
  function makeNonBlockingUpdateHandler(pi: ExtensionAPI): (update: HandsFreeUpdate) => void {
138
75
  return (update) => {
139
76
  pi.events.emit("interactive-shell:update", update);
140
-
141
- if (update.status !== "running") {
142
- const tail = update.tail.length > 0 ? `\n\n${update.tail.join("\n")}` : "";
143
- let statusLine: string;
144
- switch (update.status) {
145
- case "exited":
146
- statusLine = `Session ${update.sessionId} exited (${formatDurationMs(update.runtime)})`;
147
- break;
148
- case "killed":
149
- statusLine = `Session ${update.sessionId} killed (${formatDurationMs(update.runtime)})`;
150
- break;
151
- case "user-takeover":
152
- statusLine = `Session ${update.sessionId}: user took over (${formatDurationMs(update.runtime)})`;
153
- break;
154
- default:
155
- statusLine = `Session ${update.sessionId} update (${formatDurationMs(update.runtime)})`;
156
- }
157
- pi.sendMessage({
158
- customType: "interactive-shell-update",
159
- content: statusLine + tail,
160
- display: true,
161
- details: update,
162
- }, { triggerTurn: true });
163
- }
164
- };
165
- }
166
-
167
- let bgWidgetCleanup: (() => void) | null = null;
168
-
169
- function setupBackgroundWidget(ctx: { ui: { setWidget: Function }; hasUI?: boolean }) {
170
- if (!ctx.hasUI) return;
171
-
172
- bgWidgetCleanup?.();
173
-
174
- let durationTimer: ReturnType<typeof setInterval> | null = null;
175
- let tuiRef: { requestRender: () => void } | null = null;
176
-
177
- const requestRender = () => tuiRef?.requestRender();
178
-
179
- const unsubscribe = sessionManager.onChange(() => {
180
- manageDurationTimer();
181
- requestRender();
182
- });
183
-
184
- function manageDurationTimer() {
185
- const sessions = sessionManager.list();
186
- const hasRunning = sessions.some((s) => !s.session.exited);
187
- if (hasRunning && !durationTimer) {
188
- durationTimer = setInterval(requestRender, 10_000);
189
- } else if (!hasRunning && durationTimer) {
190
- clearInterval(durationTimer);
191
- durationTimer = null;
192
- }
193
- }
194
-
195
- ctx.ui.setWidget(
196
- "bg-sessions",
197
- (tui: any, theme: any) => {
198
- tuiRef = tui;
199
- return {
200
- render: (width: number) => {
201
- const sessions = sessionManager.list();
202
- if (sessions.length === 0) return [];
203
- const cols = width || tui.terminal?.columns || 120;
204
- const lines: string[] = [];
205
- for (const s of sessions) {
206
- const exited = s.session.exited;
207
- const dot = exited ? theme.fg("dim", "○") : theme.fg("accent", "●");
208
- const id = theme.fg("dim", s.id);
209
- const cmd = s.command.replace(/\s+/g, " ").trim();
210
- const truncCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
211
- const reason = s.reason ? theme.fg("dim", ` · ${s.reason}`) : "";
212
- const status = exited ? theme.fg("dim", "exited") : theme.fg("success", "running");
213
- const duration = theme.fg("dim", formatDuration(Date.now() - s.startedAt.getTime()));
214
- const oneLine = ` ${dot} ${id} ${truncCmd}${reason} ${status} ${duration}`;
215
- if (visibleWidth(oneLine) <= cols) {
216
- lines.push(oneLine);
217
- } else {
218
- lines.push(truncateToWidth(` ${dot} ${id} ${cmd}`, cols, "…"));
219
- lines.push(truncateToWidth(` ${status} ${duration}${reason}`, cols, "…"));
220
- }
221
- }
222
- return lines;
223
- },
224
- invalidate: () => {},
225
- };
226
- },
227
- { placement: "belowEditor" },
228
- );
229
-
230
- manageDurationTimer();
231
-
232
- bgWidgetCleanup = () => {
233
- unsubscribe();
234
- if (durationTimer) {
235
- clearInterval(durationTimer);
236
- durationTimer = null;
237
- }
238
- ctx.ui.setWidget("bg-sessions", undefined);
239
- bgWidgetCleanup = null;
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 });
240
85
  };
241
86
  }
242
87
 
243
88
  export default function interactiveShellExtension(pi: ExtensionAPI) {
244
- pi.on("session_start", (_event, ctx) => setupBackgroundWidget(ctx));
245
- 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
+ });
246
95
 
247
96
  pi.on("session_shutdown", () => {
248
- bgWidgetCleanup?.();
97
+ coordinator.clearBackgroundWidget();
249
98
  sessionManager.killAll();
250
- for (const [id, monitor] of headlessMonitors) {
251
- monitor.dispose();
252
- headlessMonitors.delete(id);
253
- }
99
+ coordinator.disposeAllMonitors();
254
100
  });
255
101
 
256
102
  pi.registerTool({
@@ -306,9 +152,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
306
152
 
307
153
  // Kill
308
154
  if (kill) {
309
- const hMonitor = headlessMonitors.get(sessionId);
155
+ const hMonitor = coordinator.getMonitor(sessionId);
310
156
  if (!hMonitor || hMonitor.disposed) {
311
- agentHandledCompletion = true;
157
+ coordinator.markAgentHandledCompletion(sessionId);
312
158
  }
313
159
  const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
314
160
  const status = session.getStatus();
@@ -332,14 +178,14 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
332
178
  details: session.getResult(),
333
179
  };
334
180
  }
335
- const bMonitor = headlessMonitors.get(sessionId);
181
+ const bMonitor = coordinator.getMonitor(sessionId);
336
182
  if (!bMonitor || bMonitor.disposed) {
337
- agentHandledCompletion = true;
183
+ coordinator.markAgentHandledCompletion(sessionId);
338
184
  }
339
185
  session.background();
340
186
  const result = session.getResult();
341
187
  if (!result || !result.backgrounded) {
342
- agentHandledCompletion = false;
188
+ coordinator.consumeAgentHandledCompletion(sessionId);
343
189
  return {
344
190
  content: [{ type: "text", text: `Session ${sessionId} is already running in the background.` }],
345
191
  details: { sessionId },
@@ -482,7 +328,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
482
328
  isError: true,
483
329
  };
484
330
  }
485
- if (overlayOpen) {
331
+ if (coordinator.isOverlayOpen()) {
486
332
  return {
487
333
  content: [{ type: "text", text: "An interactive shell overlay is already open." }],
488
334
  isError: true,
@@ -490,6 +336,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
490
336
  };
491
337
  }
492
338
 
339
+ const monitor = coordinator.getMonitor(attach);
493
340
  const bgSession = sessionManager.take(attach);
494
341
  if (!bgSession) {
495
342
  return {
@@ -498,54 +345,74 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
498
345
  };
499
346
  }
500
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
+
501
364
  const config = loadConfig(cwd ?? ctx.cwd);
502
365
  const reattachSessionId = attach;
503
- const monitor = headlessMonitors.get(attach);
504
-
505
366
  const isNonBlocking = mode === "hands-free" || mode === "dispatch";
506
-
507
- overlayOpen = true;
508
- const attachStartTime = Date.now();
509
- const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
510
- (tui, theme, _kb, done) =>
511
- new InteractiveShellOverlay(tui, theme, {
512
- command: bgSession.command,
513
- existingSession: bgSession.session,
514
- sessionId: reattachSessionId,
515
- mode,
516
- cwd: cwd ?? ctx.cwd,
517
- name: bgSession.name,
518
- reason: bgSession.reason ?? reason,
519
- handsFreeUpdateMode: handsFree?.updateMode,
520
- handsFreeUpdateInterval: handsFree?.updateInterval,
521
- handsFreeQuietThreshold: handsFree?.quietThreshold,
522
- handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
523
- handsFreeMaxTotalChars: handsFree?.maxTotalChars,
524
- autoExitOnQuiet: mode === "dispatch"
525
- ? handsFree?.autoExitOnQuiet !== false
526
- : handsFree?.autoExitOnQuiet === true,
527
- autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
528
- onHandsFreeUpdate: mode === "hands-free"
529
- ? makeNonBlockingUpdateHandler(pi)
530
- : undefined,
531
- handoffPreviewEnabled: handoffPreview?.enabled,
532
- handoffPreviewLines: handoffPreview?.lines,
533
- handoffPreviewMaxChars: handoffPreview?.maxChars,
534
- handoffSnapshotEnabled: handoffSnapshot?.enabled,
535
- handoffSnapshotLines: handoffSnapshot?.lines,
536
- handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
537
- timeout,
538
- }, config, done),
539
- {
540
- overlay: true,
541
- overlayOptions: {
542
- width: `${config.overlayWidthPercent}%`,
543
- maxHeight: `${config.overlayHeightPercent}%`,
544
- anchor: "center",
545
- 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
+ },
546
409
  },
547
- },
548
- );
410
+ );
411
+ } catch (error) {
412
+ coordinator.endOverlay();
413
+ restoreAttachSession();
414
+ throw error;
415
+ }
549
416
 
550
417
  if (isNonBlocking) {
551
418
  setupDispatchCompletion(pi, overlayPromise, config, {
@@ -556,6 +423,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
556
423
  timeout,
557
424
  handsFree,
558
425
  overlayStartTime: attachStartTime,
426
+ onOverlayError: restoreAttachSession,
559
427
  });
560
428
  return {
561
429
  content: [{ type: "text", text: mode === "dispatch"
@@ -565,39 +433,27 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
565
433
  };
566
434
  }
567
435
 
568
- // Blocking (interactive) attach
569
436
  let result: InteractiveShellResult;
570
437
  try {
571
438
  result = await overlayPromise;
439
+ } catch (error) {
440
+ restoreAttachSession();
441
+ throw error;
572
442
  } finally {
573
- overlayOpen = false;
574
- }
575
- if (monitor) {
576
- monitor.dispose();
577
- headlessMonitors.delete(attach);
578
- sessionManager.unregisterActive(attach, !result.backgrounded);
579
- } else if (!result.backgrounded) {
580
- releaseSessionId(attach);
443
+ coordinator.endOverlay();
581
444
  }
582
-
583
- let summary: string;
584
- if (result.transferred) {
585
- const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
586
- 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
+ }
587
450
  } else if (result.backgrounded) {
588
- summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
589
- } else if (result.cancelled) {
590
- summary = "Session killed";
591
- } else if (result.timedOut) {
592
- summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
451
+ sessionManager.restartAutoCleanup(attach);
593
452
  } else {
594
- const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
595
- summary = `Session ended ${status}`;
596
- }
597
- if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
598
- summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
453
+ sessionManager.scheduleCleanup(attach);
599
454
  }
600
- 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 };
601
457
  }
602
458
 
603
459
  // ── Branch 3: List background sessions ──
@@ -632,11 +488,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
632
488
  }
633
489
 
634
490
  for (const tid of targetIds) {
635
- const monitor = headlessMonitors.get(tid);
636
- if (monitor) {
637
- monitor.dispose();
638
- headlessMonitors.delete(tid);
639
- }
491
+ coordinator.disposeMonitor(tid);
640
492
  sessionManager.unregisterActive(tid, false);
641
493
  sessionManager.remove(tid);
642
494
  }
@@ -665,17 +517,18 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
665
517
  const session = new PtyTerminalSession(
666
518
  { command, cwd: effectiveCwd, cols: 120, rows: 40, scrollback: config.scrollbackLines },
667
519
  );
668
- sessionManager.add(command, session, name, reason, { id, noAutoCleanup: true });
669
520
 
670
521
  const startTime = Date.now();
522
+ sessionManager.add(command, session, name, reason, { id, noAutoCleanup: true, startedAt: new Date(startTime) });
523
+
671
524
  const monitor = new HeadlessDispatchMonitor(session, config, {
672
525
  autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
673
526
  quietThreshold: handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
674
527
  gracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
675
528
  timeout,
529
+ startedAt: startTime,
676
530
  }, makeMonitorCompletionCallback(pi, id, startTime));
677
- headlessMonitors.set(id, monitor);
678
- registerHeadlessActive(id, command, reason, session, monitor, startTime);
531
+ registerHeadlessActive(id, command, reason, session, monitor, startTime, config);
679
532
 
680
533
  return {
681
534
  content: [{ type: "text", text: `Session dispatched in background (id: ${id}).\nYou'll be notified when it completes. User can /attach ${id} to watch.` }],
@@ -698,7 +551,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
698
551
  };
699
552
  }
700
553
 
701
- if (overlayOpen) {
554
+ if (coordinator.isOverlayOpen()) {
702
555
  return {
703
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." }],
704
557
  isError: true,
@@ -710,48 +563,61 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
710
563
 
711
564
  // ── Non-blocking path (hands-free or dispatch) ──
712
565
  if (isNonBlocking && generatedSessionId) {
713
- 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
+ }
714
573
  const overlayStartTime = Date.now();
715
574
 
716
- const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
717
- (tui, theme, _kb, done) =>
718
- new InteractiveShellOverlay(tui, theme, {
719
- command,
720
- cwd: effectiveCwd,
721
- name,
722
- reason,
723
- mode,
724
- sessionId: generatedSessionId,
725
- handsFreeUpdateMode: handsFree?.updateMode,
726
- handsFreeUpdateInterval: handsFree?.updateInterval,
727
- handsFreeQuietThreshold: handsFree?.quietThreshold,
728
- handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
729
- handsFreeMaxTotalChars: handsFree?.maxTotalChars,
730
- autoExitOnQuiet: mode === "dispatch"
731
- ? handsFree?.autoExitOnQuiet !== false
732
- : handsFree?.autoExitOnQuiet === true,
733
- autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
734
- onHandsFreeUpdate: mode === "hands-free"
735
- ? makeNonBlockingUpdateHandler(pi)
736
- : undefined,
737
- handoffPreviewEnabled: handoffPreview?.enabled,
738
- handoffPreviewLines: handoffPreview?.lines,
739
- handoffPreviewMaxChars: handoffPreview?.maxChars,
740
- handoffSnapshotEnabled: handoffSnapshot?.enabled,
741
- handoffSnapshotLines: handoffSnapshot?.lines,
742
- handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
743
- timeout,
744
- }, config, done),
745
- {
746
- overlay: true,
747
- overlayOptions: {
748
- width: `${config.overlayWidthPercent}%`,
749
- maxHeight: `${config.overlayHeightPercent}%`,
750
- anchor: "center",
751
- 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
+ },
752
615
  },
753
- },
754
- );
616
+ );
617
+ } catch (error) {
618
+ coordinator.endOverlay();
619
+ throw error;
620
+ }
755
621
 
756
622
  setupDispatchCompletion(pi, overlayPromise, config, {
757
623
  id: generatedSessionId,
@@ -776,7 +642,13 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
776
642
  }
777
643
 
778
644
  // ── Blocking (interactive) path ──
779
- 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
+ }
780
652
  onUpdate?.({
781
653
  content: [{ type: "text", text: `Opening: ${command}` }],
782
654
  details: { exitCode: null, backgrounded: false, cancelled: false },
@@ -856,45 +728,17 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
856
728
  },
857
729
  );
858
730
  } finally {
859
- overlayOpen = false;
731
+ coordinator.endOverlay();
860
732
  }
861
733
 
862
- let summary: string;
863
- if (result.transferred) {
864
- const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
865
- summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
866
- } else if (result.backgrounded) {
867
- summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
868
- } else if (result.cancelled) {
869
- summary = "User killed the interactive session";
870
- } else if (result.timedOut) {
871
- summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
872
- } else {
873
- const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
874
- summary = `Session ended ${status}`;
875
- }
876
-
877
- if (result.userTookOver) {
878
- summary += "\n\nNote: User took over control during hands-free mode.";
879
- }
880
-
881
- const warning = buildIdlePromptWarning(command, reason);
882
- if (warning) {
883
- summary += `\n\n${warning}`;
884
- }
885
-
886
- if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
887
- summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
888
- }
889
-
890
- return { content: [{ type: "text", text: summary }], details: result };
734
+ return { content: [{ type: "text", text: summarizeInteractiveResult(command, result, timeout, reason) }], details: result };
891
735
  },
892
736
  });
893
737
 
894
738
  pi.registerCommand("attach", {
895
739
  description: "Reattach to a background shell session",
896
740
  handler: async (args, ctx) => {
897
- if (overlayOpen) {
741
+ if (coordinator.isOverlayOpen()) {
898
742
  ctx.ui.notify("An overlay is already open. Close it first.", "error");
899
743
  return;
900
744
  }
@@ -920,7 +764,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
920
764
  targetId = choice.split(" - ")[0]!;
921
765
  }
922
766
 
923
- const monitor = headlessMonitors.get(targetId);
767
+ const monitor = coordinator.getMonitor(targetId);
924
768
 
925
769
  const session = sessionManager.get(targetId);
926
770
  if (!session) {
@@ -929,7 +773,10 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
929
773
  }
930
774
 
931
775
  const config = loadConfig(ctx.cwd);
932
- overlayOpen = true;
776
+ if (!coordinator.beginOverlay()) {
777
+ ctx.ui.notify("An overlay is already open. Close it first.", "error");
778
+ return;
779
+ }
933
780
  try {
934
781
  const result = await ctx.ui.custom<InteractiveShellResult>(
935
782
  (tui, theme, _kb, done) =>
@@ -948,7 +795,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
948
795
  if (monitor && !monitor.disposed) {
949
796
  if (!result.backgrounded) {
950
797
  monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
951
- headlessMonitors.delete(targetId);
798
+ coordinator.deleteMonitor(targetId);
952
799
  }
953
800
  } else if (result.backgrounded) {
954
801
  sessionManager.restartAutoCleanup(targetId);
@@ -956,7 +803,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
956
803
  sessionManager.scheduleCleanup(targetId);
957
804
  }
958
805
  } finally {
959
- overlayOpen = false;
806
+ coordinator.endOverlay();
960
807
  }
961
808
  },
962
809
  });
@@ -994,11 +841,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
994
841
  }
995
842
 
996
843
  for (const tid of targetIds) {
997
- const monitor = headlessMonitors.get(tid);
998
- if (monitor) {
999
- monitor.dispose();
1000
- headlessMonitors.delete(tid);
1001
- }
844
+ coordinator.disposeMonitor(tid);
1002
845
  sessionManager.unregisterActive(tid, false);
1003
846
  sessionManager.remove(tid);
1004
847
  }
@@ -1021,15 +864,15 @@ function setupDispatchCompletion(
1021
864
  timeout?: number;
1022
865
  handsFree?: { autoExitOnQuiet?: boolean; quietThreshold?: number; gracePeriod?: number };
1023
866
  overlayStartTime?: number;
867
+ onOverlayError?: () => { releaseId?: boolean; disposeMonitor?: boolean } | void;
1024
868
  },
1025
869
  ): void {
1026
870
  const { id, mode, command, reason } = ctx;
1027
871
 
1028
872
  overlayPromise.then((result) => {
1029
- overlayOpen = false;
873
+ coordinator.endOverlay();
1030
874
 
1031
- const wasAgentInitiated = agentHandledCompletion;
1032
- agentHandledCompletion = false;
875
+ const wasAgentInitiated = coordinator.consumeAgentHandledCompletion(id);
1033
876
 
1034
877
  if (result.transferred) {
1035
878
  const truncatedNote = result.transferred.truncated
@@ -1044,10 +887,11 @@ function setupDispatchCompletion(
1044
887
  }, { triggerTurn: true });
1045
888
  pi.events.emit("interactive-shell:transfer", { sessionId: id, transferred: result.transferred, exitCode: result.exitCode, signal: result.signal });
1046
889
  sessionManager.unregisterActive(id, true);
890
+ coordinator.disposeMonitor(id);
891
+ return;
892
+ }
1047
893
 
1048
- const remainingMonitor = headlessMonitors.get(id);
1049
- if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
1050
- } else if (mode === "dispatch" && result.backgrounded) {
894
+ if (mode === "dispatch" && result.backgrounded) {
1051
895
  if (!wasAgentInitiated) {
1052
896
  pi.sendMessage({
1053
897
  customType: "interactive-shell-transfer",
@@ -1058,31 +902,32 @@ function setupDispatchCompletion(
1058
902
  }
1059
903
  sessionManager.unregisterActive(id, false);
1060
904
 
1061
- 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
+
1062
910
  if (existingMonitor && !existingMonitor.disposed) {
1063
- const bgSession = sessionManager.get(result.backgroundId!);
1064
- if (bgSession) {
1065
- registerHeadlessActive(result.backgroundId!, command, reason, bgSession.session, existingMonitor, existingMonitor.startTime);
1066
- }
1067
- } else if (!existingMonitor) {
1068
- const bgSession = sessionManager.get(result.backgroundId!);
1069
- if (bgSession) {
1070
- const bgId = result.backgroundId!;
1071
- const bgStartTime = ctx.overlayStartTime ?? Date.now();
1072
- const elapsed = ctx.overlayStartTime ? Date.now() - ctx.overlayStartTime : 0;
1073
- const remainingTimeout = ctx.timeout ? Math.max(0, ctx.timeout - elapsed) : undefined;
1074
-
1075
- const monitor = new HeadlessDispatchMonitor(bgSession.session, config, {
1076
- autoExitOnQuiet: ctx.handsFree?.autoExitOnQuiet !== false,
1077
- quietThreshold: ctx.handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
1078
- gracePeriod: ctx.handsFree?.gracePeriod ?? config.autoExitGracePeriod,
1079
- timeout: remainingTimeout,
1080
- }, makeMonitorCompletionCallback(pi, bgId, bgStartTime));
1081
- headlessMonitors.set(bgId, monitor);
1082
- registerHeadlessActive(bgId, command, reason, bgSession.session, monitor, bgStartTime);
1083
- }
911
+ coordinator.deleteMonitor(id);
912
+ registerHeadlessActive(bgId, command, reason, bgSession.session, existingMonitor, bgSession.startedAt.getTime(), config);
913
+ return;
1084
914
  }
1085
- } 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") {
1086
931
  if (!wasAgentInitiated) {
1087
932
  const content = buildResultNotification(id, result);
1088
933
  pi.sendMessage({
@@ -1101,47 +946,17 @@ function setupDispatchCompletion(
1101
946
  cancelled: result.cancelled,
1102
947
  });
1103
948
  sessionManager.unregisterActive(id, true);
1104
-
1105
- const remainingMonitor = headlessMonitors.get(id);
1106
- if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
949
+ coordinator.disposeMonitor(id);
950
+ return;
1107
951
  }
1108
952
 
1109
- if (mode !== "dispatch") {
1110
- const staleMonitor = headlessMonitors.get(id);
1111
- if (staleMonitor) { staleMonitor.dispose(); headlessMonitors.delete(id); }
1112
- }
953
+ coordinator.disposeMonitor(id);
1113
954
  }).catch(() => {
1114
- overlayOpen = false;
1115
- sessionManager.unregisterActive(id, true);
1116
- const orphanedMonitor = headlessMonitors.get(id);
1117
- 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
+ }
1118
961
  });
1119
962
  }
1120
-
1121
- function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
1122
- if (!reason) return null;
1123
-
1124
- const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
1125
- if (!tasky.test(reason)) return null;
1126
-
1127
- const trimmed = command.trim();
1128
- const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
1129
- const bin = binaries.find((b) => trimmed === b || trimmed.startsWith(`${b} `));
1130
- if (!bin) return null;
1131
-
1132
- const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
1133
- const hasQuotedPrompt = /["']/.test(rest);
1134
- const hasKnownPromptFlag =
1135
- /\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
1136
- (bin === "pi" && /\b-p\b/.test(rest)) ||
1137
- (bin === "codex" && /\bexec\b/.test(rest));
1138
-
1139
- if (hasQuotedPrompt || hasKnownPromptFlag) return null;
1140
- if (rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest)) {
1141
- const examplePrompt = reason.replace(/\s+/g, " ").trim();
1142
- const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
1143
- 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}"\`.`;
1144
- }
1145
-
1146
- return null;
1147
- }