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.
@@ -0,0 +1,134 @@
1
+ import type { InteractiveShellResult, HandsFreeUpdate } from "./types.js";
2
+ import type { HeadlessCompletionInfo } from "./headless-monitor.js";
3
+ import { formatDurationMs } from "./types.js";
4
+
5
+ const BRIEF_TAIL_LINES = 5;
6
+
7
+ export function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
8
+ const parts = [buildDispatchStatusLine(sessionId, info, duration)];
9
+ if (info.completionOutput && info.completionOutput.totalLines > 0) {
10
+ parts.push(` ${info.completionOutput.totalLines} lines of output.`);
11
+ }
12
+ appendTailBlock(parts, info.completionOutput?.lines, BRIEF_TAIL_LINES);
13
+ parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
14
+ return parts.join("");
15
+ }
16
+
17
+ export function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
18
+ const parts = [buildResultStatusLine(sessionId, result)];
19
+ if (result.completionOutput && result.completionOutput.lines.length > 0) {
20
+ const truncNote = result.completionOutput.truncated
21
+ ? ` (truncated from ${result.completionOutput.totalLines} total lines)`
22
+ : "";
23
+ parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
24
+ }
25
+ return parts.join("");
26
+ }
27
+
28
+ export function buildHandsFreeUpdateMessage(update: HandsFreeUpdate): { content: string; details: HandsFreeUpdate } | null {
29
+ if (update.status === "running") return null;
30
+
31
+ const tail = update.tail.length > 0 ? `\n\n${update.tail.join("\n")}` : "";
32
+ let statusLine: string;
33
+ switch (update.status) {
34
+ case "exited":
35
+ statusLine = `Session ${update.sessionId} exited (${formatDurationMs(update.runtime)})`;
36
+ break;
37
+ case "killed":
38
+ statusLine = `Session ${update.sessionId} killed (${formatDurationMs(update.runtime)})`;
39
+ break;
40
+ case "user-takeover":
41
+ statusLine = `Session ${update.sessionId}: user took over (${formatDurationMs(update.runtime)})`;
42
+ break;
43
+ default:
44
+ statusLine = `Session ${update.sessionId} update (${formatDurationMs(update.runtime)})`;
45
+ }
46
+ return { content: statusLine + tail, details: update };
47
+ }
48
+
49
+ export function summarizeInteractiveResult(command: string, result: InteractiveShellResult, timeout?: number, reason?: string): string {
50
+ let summary = buildInteractiveSummary(result, timeout);
51
+
52
+ if (result.userTookOver) {
53
+ summary += "\n\nNote: User took over control during hands-free mode.";
54
+ }
55
+
56
+ if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
57
+ summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
58
+ }
59
+
60
+ const warning = buildIdlePromptWarning(command, reason);
61
+ if (warning) {
62
+ summary += `\n\n${warning}`;
63
+ }
64
+
65
+ return summary;
66
+ }
67
+
68
+ export function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
69
+ if (!reason) return null;
70
+
71
+ const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
72
+ if (!tasky.test(reason)) return null;
73
+
74
+ const trimmed = command.trim();
75
+ const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
76
+ const bin = binaries.find((candidate) => trimmed === candidate || trimmed.startsWith(`${candidate} `));
77
+ if (!bin) return null;
78
+
79
+ const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
80
+ const hasQuotedPrompt = /["']/.test(rest);
81
+ const hasKnownPromptFlag =
82
+ /\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
83
+ (bin === "pi" && /\b-p\b/.test(rest)) ||
84
+ (bin === "codex" && /\bexec\b/.test(rest));
85
+
86
+ if (hasQuotedPrompt || hasKnownPromptFlag) return null;
87
+ if (!looksLikeIdleCommand(rest)) return null;
88
+
89
+ const examplePrompt = reason.replace(/\s+/g, " ").trim();
90
+ const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
91
+ 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}"\`.`;
92
+ }
93
+
94
+ function buildDispatchStatusLine(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
95
+ if (info.timedOut) return `Session ${sessionId} timed out (${duration}).`;
96
+ if (info.cancelled) return `Session ${sessionId} completed (${duration}).`;
97
+ if (info.exitCode === 0) return `Session ${sessionId} completed successfully (${duration}).`;
98
+ return `Session ${sessionId} exited with code ${info.exitCode} (${duration}).`;
99
+ }
100
+
101
+ function buildResultStatusLine(sessionId: string, result: InteractiveShellResult): string {
102
+ if (result.timedOut) return `Session ${sessionId} timed out.`;
103
+ if (result.cancelled) return `Session ${sessionId} was killed.`;
104
+ if (result.exitCode === 0) return `Session ${sessionId} completed successfully.`;
105
+ return `Session ${sessionId} exited with code ${result.exitCode}.`;
106
+ }
107
+
108
+ function buildInteractiveSummary(result: InteractiveShellResult, timeout?: number): string {
109
+ if (result.transferred) {
110
+ const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
111
+ return `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
112
+ }
113
+ if (result.backgrounded) {
114
+ return `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
115
+ }
116
+ if (result.cancelled) return "User killed the interactive session";
117
+ if (result.timedOut) return `Session killed after timeout (${timeout ?? "?"}ms)`;
118
+ const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
119
+ return `Session ended ${status}`;
120
+ }
121
+
122
+ function appendTailBlock(parts: string[], lines: string[] | undefined, tailLines: number): void {
123
+ if (!lines || lines.length === 0) return;
124
+ let end = lines.length;
125
+ while (end > 0 && lines[end - 1].trim() === "") end--;
126
+ const tail = lines.slice(Math.max(0, end - tailLines), end);
127
+ if (tail.length > 0) {
128
+ parts.push(`\n\n${tail.join("\n")}`);
129
+ }
130
+ }
131
+
132
+ function looksLikeIdleCommand(rest: string): boolean {
133
+ return rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest);
134
+ }
@@ -1,6 +1,4 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { stripVTControlCharacters } from "node:util";
4
2
  import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
5
3
  import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
4
  import type { Theme } from "@mariozechner/pi-coding-agent";
@@ -18,6 +16,8 @@ import {
18
16
  FOOTER_LINES_DIALOG,
19
17
  formatDuration,
20
18
  } from "./types.js";
19
+ import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
20
+ import { createSessionQueryState, getSessionOutput } from "./session-query.js";
21
21
 
22
22
  export class InteractiveShellOverlay implements Component, Focusable {
23
23
  focused = false;
@@ -39,7 +39,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
39
39
  private userTookOver = false;
40
40
  private handsFreeInterval: ReturnType<typeof setInterval> | null = null;
41
41
  private handsFreeInitialTimeout: ReturnType<typeof setTimeout> | null = null;
42
- private startTime = Date.now();
42
+ private startTime: number;
43
43
  private sessionId: string | null = null;
44
44
  private sessionUnregistered = false;
45
45
  // Timeout
@@ -56,10 +56,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
56
56
  private hasUnsentData = false;
57
57
  // Non-blocking mode: track status for agent queries
58
58
  private completionResult: InteractiveShellResult | undefined;
59
- // Rate limiting for queries
60
- private lastQueryTime = 0;
61
- // Incremental read position (for incremental: true queries)
62
- private incrementalReadPosition = 0;
59
+ private queryState = createSessionQueryState();
63
60
  // Completion callbacks for waiters
64
61
  private completeCallbacks: Array<() => void> = [];
65
62
  // Simple render throttle to reduce flicker
@@ -77,6 +74,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
77
74
  this.options = options;
78
75
  this.config = config;
79
76
  this.done = done;
77
+ this.startTime = options.startedAt ?? Date.now();
80
78
 
81
79
  const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
82
80
  const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
@@ -84,11 +82,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
84
82
  const rows = Math.max(3, overlayHeight - (HEADER_LINES + FOOTER_LINES_COMPACT + 2));
85
83
 
86
84
  const ptyEvents = {
87
- onData: () => {
85
+ onData: (data: string) => {
88
86
  this.debouncedRender();
89
- if (this.state === "hands-free" && this.updateMode === "on-quiet") {
90
- this.hasUnsentData = true;
91
- this.resetQuietTimer();
87
+ if (this.state === "hands-free" && (this.updateMode === "on-quiet" || this.options.autoExitOnQuiet)) {
88
+ const visible = stripVTControlCharacters(data);
89
+ if (visible.trim().length > 0) {
90
+ if (this.updateMode === "on-quiet") {
91
+ this.hasUnsentData = true;
92
+ }
93
+ this.resetQuietTimer();
94
+ }
92
95
  }
93
96
  },
94
97
  onExit: () => {
@@ -203,136 +206,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
203
206
 
204
207
  // Public methods for non-blocking mode (agent queries)
205
208
 
206
- // Default output limits per status query
207
- private static readonly DEFAULT_STATUS_OUTPUT = 5 * 1024; // 5KB
208
- private static readonly DEFAULT_STATUS_LINES = 20;
209
- private static readonly MAX_STATUS_OUTPUT = 50 * 1024; // 50KB max
210
- private static readonly MAX_STATUS_LINES = 200; // 200 lines max
211
-
212
209
  /** Get rendered terminal output (last N lines, truncated if too large) */
213
210
  getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number; offset?: number; drain?: boolean; incremental?: boolean } | boolean = false): { output: string; truncated: boolean; totalBytes: number; totalLines?: number; hasMore?: boolean; rateLimited?: boolean; waitSeconds?: number } {
214
- if (this.finished) {
215
- if (this.completionResult?.completionOutput) {
216
- const lines = this.completionResult.completionOutput.lines;
217
- const output = lines.join("\n");
218
- return {
219
- output,
220
- truncated: this.completionResult.completionOutput.truncated,
221
- totalBytes: output.length,
222
- totalLines: this.completionResult.completionOutput.totalLines,
223
- };
224
- }
225
- return { output: "", truncated: false, totalBytes: 0 };
226
- }
227
-
228
- // Handle legacy boolean parameter
229
- const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
230
- const skipRateLimit = opts.skipRateLimit ?? false;
231
- // Clamp lines and maxChars to valid ranges (1 to MAX)
232
- const requestedLines = Math.max(1, Math.min(
233
- opts.lines ?? InteractiveShellOverlay.DEFAULT_STATUS_LINES,
234
- InteractiveShellOverlay.MAX_STATUS_LINES
235
- ));
236
- const requestedMaxChars = Math.max(1, Math.min(
237
- opts.maxChars ?? InteractiveShellOverlay.DEFAULT_STATUS_OUTPUT,
238
- InteractiveShellOverlay.MAX_STATUS_OUTPUT
239
- ));
240
-
241
- // Check rate limiting (unless skipped, e.g., for completed sessions)
242
- if (!skipRateLimit) {
243
- const now = Date.now();
244
- const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
245
- const elapsed = now - this.lastQueryTime;
246
-
247
- if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
248
- const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
249
- return {
250
- output: "",
251
- truncated: false,
252
- totalBytes: 0,
253
- rateLimited: true,
254
- waitSeconds,
255
- };
256
- }
257
-
258
- // Update last query time
259
- this.lastQueryTime = now;
260
- }
261
-
262
- // Incremental mode: return next N lines agent hasn't seen yet
263
- // Server tracks position - agent just keeps calling with incremental: true
264
- if (opts.incremental) {
265
- const result = this.session.getLogSlice({
266
- offset: this.incrementalReadPosition,
267
- limit: requestedLines,
268
- stripAnsi: true,
269
- });
270
- // Use sliceLineCount directly - handles empty lines correctly
271
- // (counting newlines in slice fails for empty lines like "")
272
- const linesFromSlice = result.sliceLineCount;
273
- // Apply maxChars limit (may truncate mid-line, but we still advance past it)
274
- const truncatedByChars = result.slice.length > requestedMaxChars;
275
- const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
276
- // Update position for next incremental read
277
- this.incrementalReadPosition += linesFromSlice;
278
- const hasMore = this.incrementalReadPosition < result.totalLines;
279
- return {
280
- output,
281
- truncated: truncatedByChars,
282
- totalBytes: output.length,
283
- totalLines: result.totalLines,
284
- hasMore,
285
- };
286
- }
287
-
288
- // Drain mode: return only NEW output since last query (raw stream, not lines)
289
- // This is more token-efficient than re-reading the tail each time
290
- if (opts.drain) {
291
- const newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true });
292
- // Truncate if exceeds maxChars
293
- const truncated = newOutput.length > requestedMaxChars;
294
- const output = truncated ? newOutput.slice(-requestedMaxChars) : newOutput;
295
- return {
296
- output,
297
- truncated,
298
- totalBytes: output.length,
299
- };
300
- }
301
-
302
- // Offset mode: use getLogSlice for pagination through full output
303
- if (opts.offset !== undefined) {
304
- const result = this.session.getLogSlice({
305
- offset: opts.offset,
306
- limit: requestedLines,
307
- stripAnsi: true,
308
- });
309
- // Apply maxChars limit
310
- const truncatedByChars = result.slice.length > requestedMaxChars;
311
- const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
312
- // Calculate hasMore based on whether there are more lines after this slice
313
- const hasMore = (opts.offset + result.sliceLineCount) < result.totalLines;
314
- return {
315
- output,
316
- truncated: truncatedByChars || result.sliceLineCount >= requestedLines,
317
- totalBytes: output.length,
318
- totalLines: result.totalLines,
319
- hasMore,
320
- };
321
- }
322
-
323
- // Default: Use rendered terminal output (tail)
324
- // This gives clean, readable content without TUI animation garbage
325
- const tailResult = this.session.getTailLines({
326
- lines: requestedLines,
327
- ansi: false,
328
- maxChars: requestedMaxChars,
329
- });
330
-
331
- const output = tailResult.lines.join("\n");
332
- const totalBytes = output.length;
333
- const truncated = tailResult.lines.length >= requestedLines || tailResult.truncatedByChars;
334
-
335
- return { output, truncated, totalBytes, totalLines: tailResult.totalLinesInBuffer };
211
+ return getSessionOutput(this.session, this.config, this.queryState, options, this.completionResult?.completionOutput);
336
212
  }
337
213
 
338
214
  /** Get current session status */
@@ -427,13 +303,21 @@ export class InteractiveShellOverlay implements Component, Focusable {
427
303
  }
428
304
  }, 2000);
429
305
 
306
+ if (this.options.autoExitOnQuiet) {
307
+ this.resetQuietTimer();
308
+ }
309
+
430
310
  this.handsFreeInterval = setInterval(() => {
431
311
  if (this.state === "hands-free") {
432
312
  if (this.updateMode === "on-quiet") {
433
313
  if (this.hasUnsentData && this.options.onHandsFreeUpdate) {
434
314
  this.emitHandsFreeUpdate();
435
315
  this.hasUnsentData = false;
436
- this.stopQuietTimer();
316
+ if (this.options.autoExitOnQuiet) {
317
+ this.resetQuietTimer();
318
+ } else {
319
+ this.stopQuietTimer();
320
+ }
437
321
  }
438
322
  } else {
439
323
  this.emitHandsFreeUpdate();
@@ -442,7 +326,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
442
326
  }, this.currentUpdateInterval);
443
327
  }
444
328
 
445
- /** Reset the quiet timer - called on each data event in on-quiet mode */
446
329
  private resetQuietTimer(): void {
447
330
  this.stopQuietTimer();
448
331
  this.quietTimer = setTimeout(() => {
@@ -510,7 +393,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
510
393
  if (this.hasUnsentData && this.options.onHandsFreeUpdate) {
511
394
  this.emitHandsFreeUpdate();
512
395
  this.hasUnsentData = false;
513
- this.stopQuietTimer();
396
+ if (this.options.autoExitOnQuiet) {
397
+ this.resetQuietTimer();
398
+ } else {
399
+ this.stopQuietTimer();
400
+ }
514
401
  }
515
402
  } else {
516
403
  this.emitHandsFreeUpdate();
@@ -526,9 +413,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
526
413
  if (clamped === this.currentQuietThreshold) return;
527
414
  this.currentQuietThreshold = clamped;
528
415
 
529
- // If a quiet timer is active, restart it with the new threshold
530
- // Use resetQuietTimer to ensure autoExitOnQuiet logic is included
531
- if (this.quietTimer && this.updateMode === "on-quiet") {
416
+ if (this.quietTimer) {
532
417
  this.resetQuietTimer();
533
418
  }
534
419
  }
@@ -624,8 +509,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
624
509
  this.state = "running";
625
510
  this.userTookOver = true;
626
511
 
627
- // Notify agent that user took over (streaming mode)
628
- // In non-blocking mode, keep session registered so agent can query status
629
512
  if (this.options.onHandsFreeUpdate) {
630
513
  this.options.onHandsFreeUpdate({
631
514
  status: "user-takeover",
@@ -637,103 +520,35 @@ export class InteractiveShellOverlay implements Component, Focusable {
637
520
  totalCharsSent: this.totalCharsSent,
638
521
  budgetExhausted: this.budgetExhausted,
639
522
  });
640
- // Unregister and release ID in streaming mode - agent got notified, won't query
523
+ }
524
+ // In streaming mode (blocking tool call), unregister now since the agent
525
+ // gets the result via tool return. Otherwise keep registered for queries.
526
+ if (this.options.streamingMode) {
641
527
  this.unregisterActiveSession(true);
642
528
  }
643
- // In non-blocking mode (no onHandsFreeUpdate), keep session registered
644
- // so agent can query and see "user-takeover" status
645
529
 
646
530
  this.tui.requestRender();
647
531
  }
648
532
 
649
533
  /** Capture output for dispatch completion notifications */
650
534
  private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
651
- const result = this.session.getTailLines({
652
- lines: this.config.completionNotifyLines,
653
- ansi: false,
654
- maxChars: this.config.completionNotifyMaxChars,
655
- });
656
- return {
657
- lines: result.lines,
658
- totalLines: result.totalLinesInBuffer,
659
- truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
660
- };
535
+ return captureCompletionOutput(this.session, this.config);
661
536
  }
662
537
 
663
538
  /** Capture output for transfer action (Ctrl+T or dialog) */
664
539
  private captureTransferOutput(): InteractiveShellResult["transferred"] {
665
- const maxLines = this.config.transferLines;
666
- const maxChars = this.config.transferMaxChars;
667
-
668
- const result = this.session.getTailLines({
669
- lines: maxLines,
670
- ansi: false,
671
- maxChars,
672
- });
673
-
674
- const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
675
-
676
- return {
677
- lines: result.lines,
678
- totalLines: result.totalLinesInBuffer,
679
- truncated,
680
- };
540
+ return captureTransferOutput(this.session, this.config);
681
541
  }
682
542
 
683
543
  private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
684
- const enabled = this.options.handoffPreviewEnabled ?? this.config.handoffPreviewEnabled;
685
- if (!enabled) return undefined;
686
-
687
- const lines = this.options.handoffPreviewLines ?? this.config.handoffPreviewLines;
688
- const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
689
- if (lines <= 0 || maxChars <= 0) return undefined;
690
-
691
- const result = this.session.getTailLines({
692
- lines,
693
- ansi: false,
694
- maxChars,
695
- });
696
-
697
- return { type: "tail", when, lines: result.lines };
544
+ return maybeBuildHandoffPreview(this.session, when, this.config, this.options);
698
545
  }
699
546
 
700
547
  private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined {
701
- const enabled = this.options.handoffSnapshotEnabled ?? this.config.handoffSnapshotEnabled;
702
- if (!enabled) return undefined;
703
-
704
- const lines = this.options.handoffSnapshotLines ?? this.config.handoffSnapshotLines;
705
- const maxChars = this.options.handoffSnapshotMaxChars ?? this.config.handoffSnapshotMaxChars;
706
- if (lines <= 0 || maxChars <= 0) return undefined;
707
-
708
- const baseDir = join(homedir(), ".pi", "agent", "cache", "interactive-shell");
709
- mkdirSync(baseDir, { recursive: true });
710
-
711
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
712
- const pid = this.session.pid;
713
- const filename = `snapshot-${timestamp}-pid${pid}.log`;
714
- const transcriptPath = join(baseDir, filename);
715
-
716
- const tailResult = this.session.getTailLines({
717
- lines,
718
- ansi: this.config.ansiReemit,
719
- maxChars,
720
- });
721
-
722
- const header = [
723
- `# interactive-shell snapshot (${when})`,
724
- `time: ${new Date().toISOString()}`,
725
- `command: ${this.options.command}`,
726
- `cwd: ${this.options.cwd ?? ""}`,
727
- `pid: ${pid}`,
728
- `exitCode: ${this.session.exitCode ?? ""}`,
729
- `signal: ${this.session.signal ?? ""}`,
730
- `lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
731
- "",
732
- ].join("\n");
733
-
734
- writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
735
-
736
- return { type: "snapshot", when, transcriptPath, linesWritten: tailResult.lines.length };
548
+ return maybeWriteHandoffSnapshot(this.session, when, this.config, {
549
+ command: this.options.command,
550
+ cwd: this.options.cwd,
551
+ }, this.options);
737
552
  }
738
553
 
739
554
  private finishWithExit(): void {
@@ -761,10 +576,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
761
576
  this.completionResult = result;
762
577
  this.triggerCompleteCallbacks();
763
578
 
764
- // In non-blocking mode (no onHandsFreeUpdate), keep session registered
765
- // so agent can query completion result. Agent's query will unregister.
766
- // In streaming mode, unregister now since agent got final update.
767
- if (this.options.onHandsFreeUpdate) {
579
+ // In streaming mode (blocking tool call), unregister now since the agent
580
+ // gets the result via tool return. Otherwise keep registered for queries.
581
+ if (this.options.streamingMode) {
768
582
  this.unregisterActiveSession(true);
769
583
  }
770
584
 
@@ -785,7 +599,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
785
599
  const handoffPreview = this.maybeBuildHandoffPreview("detach");
786
600
  const handoff = this.maybeWriteHandoffSnapshot("detach");
787
601
  const addOptions = this.sessionId
788
- ? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch" }
602
+ ? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch", startedAt: new Date(this.startTime) }
789
603
  : undefined;
790
604
  const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions);
791
605
  const result: InteractiveShellResult = {
@@ -801,10 +615,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
801
615
  this.completionResult = result;
802
616
  this.triggerCompleteCallbacks();
803
617
 
804
- // In non-blocking mode (no onHandsFreeUpdate), keep session registered
805
- // so agent can query completion result. Agent's query will unregister.
806
- // Use releaseId=false because the background session now owns the ID.
807
- if (this.options.onHandsFreeUpdate) {
618
+ // In streaming mode (blocking tool call), unregister now since the agent
619
+ // gets the result via tool return. releaseId=false because background owns the ID.
620
+ if (this.options.streamingMode) {
808
621
  this.unregisterActiveSession(false);
809
622
  }
810
623
 
@@ -836,9 +649,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
836
649
  this.completionResult = result;
837
650
  this.triggerCompleteCallbacks();
838
651
 
839
- // In non-blocking mode (no onHandsFreeUpdate), keep session registered
840
- // so agent can query completion result. Agent's query will unregister.
841
- if (this.options.onHandsFreeUpdate) {
652
+ // In streaming mode (blocking tool call), unregister now since the agent
653
+ // gets the result via tool return. Otherwise keep registered for queries.
654
+ if (this.options.streamingMode) {
842
655
  this.unregisterActiveSession(true);
843
656
  }
844
657
 
@@ -875,9 +688,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
875
688
  this.completionResult = result;
876
689
  this.triggerCompleteCallbacks();
877
690
 
878
- // In non-blocking mode (no onHandsFreeUpdate), keep session registered
879
- // so agent can query completion result. Agent's query will unregister.
880
- if (this.options.onHandsFreeUpdate) {
691
+ // In streaming mode (blocking tool call), unregister now since the agent
692
+ // gets the result via tool return. Otherwise keep registered for queries.
693
+ if (this.options.streamingMode) {
881
694
  this.unregisterActiveSession(true);
882
695
  }
883
696
 
@@ -929,9 +742,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
929
742
  this.completionResult = result;
930
743
  this.triggerCompleteCallbacks();
931
744
 
932
- // In non-blocking mode (no onHandsFreeUpdate), keep session registered
933
- // so agent can query completion result. Agent's query will unregister.
934
- if (this.options.onHandsFreeUpdate) {
745
+ // In streaming mode (blocking tool call), unregister now since the agent
746
+ // gets the result via tool return. Otherwise keep registered for queries.
747
+ if (this.options.streamingMode) {
935
748
  this.unregisterActiveSession(true);
936
749
  }
937
750
 
@@ -1040,6 +853,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
1040
853
  }
1041
854
 
1042
855
  render(width: number): string[] {
856
+ width = Math.max(4, width);
1043
857
  const th = this.theme;
1044
858
  const border = (s: string) => th.fg("border", s);
1045
859
  const accent = (s: string) => th.fg("accent", s);
@@ -1176,10 +990,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
1176
990
  if (!this.completionResult) {
1177
991
  this.session.kill();
1178
992
  this.session.dispose();
1179
- // Release ID since session is dead and agent can't query anymore
1180
993
  this.unregisterActiveSession(true);
1181
- } else if (this.options.onHandsFreeUpdate) {
1182
- // Streaming mode already delivered result, safe to unregister and release
994
+ } else if (this.options.streamingMode) {
995
+ // Streaming mode already delivered result via tool return, safe to clean up
1183
996
  this.unregisterActiveSession(true);
1184
997
  }
1185
998
  // Non-blocking mode with completion: keep registered so agent can query
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.8.2",
4
- "description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
3
+ "version": "0.10.0",
4
+ "description": "Run AI coding agents in pi TUI overlays with interactive, hands-free, and dispatch supervision",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "pi-interactive-shell": "./scripts/install.js"
@@ -17,6 +17,14 @@
17
17
  "tool-schema.ts",
18
18
  "headless-monitor.ts",
19
19
  "types.ts",
20
+ "background-widget.ts",
21
+ "handoff-utils.ts",
22
+ "notification-utils.ts",
23
+ "pty-log.ts",
24
+ "pty-protocol.ts",
25
+ "runtime-coordinator.ts",
26
+ "session-query.ts",
27
+ "spawn-helper.ts",
20
28
  "scripts/",
21
29
  "examples/",
22
30
  "banner.png",
@@ -28,15 +36,24 @@
28
36
  "extensions": [
29
37
  "./index.ts"
30
38
  ],
39
+ "skills": [
40
+ "./SKILL.md",
41
+ "./examples/skills"
42
+ ],
43
+ "prompts": [
44
+ "./examples/prompts"
45
+ ],
31
46
  "video": "https://github.com/nicobailon/pi-interactive-shell/raw/refs/heads/main/pi-interactive-shell-extension.mp4"
32
47
  },
33
48
  "dependencies": {
34
- "node-pty": "^1.1.0",
49
+ "@sinclair/typebox": "^0.34.40",
50
+ "@xterm/addon-serialize": "^0.13.0",
35
51
  "@xterm/headless": "^5.5.0",
36
- "@xterm/addon-serialize": "^0.13.0"
52
+ "node-pty": "^1.1.0"
37
53
  },
38
54
  "scripts": {
39
- "postinstall": "node ./scripts/fix-spawn-helper.cjs"
55
+ "postinstall": "node ./scripts/fix-spawn-helper.cjs",
56
+ "test": "vitest run"
40
57
  },
41
58
  "keywords": [
42
59
  "pi-package",
@@ -61,5 +78,8 @@
61
78
  "bugs": {
62
79
  "url": "https://github.com/nicobailon/pi-interactive-shell/issues"
63
80
  },
64
- "homepage": "https://github.com/nicobailon/pi-interactive-shell#readme"
81
+ "homepage": "https://github.com/nicobailon/pi-interactive-shell#readme",
82
+ "devDependencies": {
83
+ "vitest": "^3.2.4"
84
+ }
65
85
  }