pi-interactive-shell 0.6.1 → 0.6.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to the `pi-interactive-shell` extension will be documented i
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.3] - 2026-01-30
8
+
9
+ ### Fixed
10
+ - **Garbled output on Ctrl+T transfer** - Transfer and handoff preview captured raw PTY output via `getRawStream()`, which includes every intermediate frame of TUI spinners (e.g., Codex's "Working" spinner produced `WorkingWorking•orking•rking•king•ing...`). Switched both `captureTransferOutput()` and `maybeBuildHandoffPreview()` to use `getTailLines()` which reads from the xterm terminal emulator buffer. The emulator correctly processes carriage returns and cursor movements, so only the final rendered state of each line is captured. Fixed in both `overlay-component.ts` and `reattach-overlay.ts`.
11
+ - **Removed dead code** - Cleaned up unused private fields (`timedOut`, `lastDataTime`) and unreachable method (`getSessionId()`) from `InteractiveShellOverlay`.
12
+
13
+ ## [0.6.2] - 2026-01-28
14
+
15
+ ### Fixed
16
+ - **Ctrl+T transfer now works in hands-free mode** - When using Ctrl+T to transfer output in non-blocking hands-free mode, the captured output is now properly sent back to the main agent using `pi.sendMessage()` with `triggerTurn: true`. Previously, the transfer data was captured but never delivered to the agent because the tool had already returned. The fix uses the event bus pattern to wake the agent with the transferred content.
17
+ - **Race condition when Ctrl+T during polling** - Added guard in `getOutputSinceLastCheck()` to return empty output if the session is finished. This prevents errors when a query races with Ctrl+T transfer (PTY disposed before query completes).
18
+
19
+ ### Added
20
+ - **New event: `interactive-shell:transfer`** - Emitted via `pi.events` when Ctrl+T transfer occurs, allowing other extensions to hook into transfer events.
21
+
7
22
  ## [0.6.1] - 2026-01-27
8
23
 
9
24
  ### Added
package/README.md CHANGED
@@ -25,11 +25,9 @@ Works with any CLI: `vim`, `htop`, `psql`, `ssh`, `docker logs -f`, `npm run dev
25
25
  ## Install
26
26
 
27
27
  ```bash
28
- npx pi-interactive-shell
28
+ pi install npm:pi-interactive-shell
29
29
  ```
30
30
 
31
- Installs to `~/.pi/agent/extensions/interactive-shell/`.
32
-
33
31
  The `interactive-shell` skill is automatically symlinked to `~/.pi/agent/skills/interactive-shell/`.
34
32
 
35
33
  **Requires:** Node.js, build tools for `node-pty` (Xcode CLI tools on macOS).
package/index.ts CHANGED
@@ -437,10 +437,37 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
437
437
  // Handle overlay completion in background (cleanup when user closes)
438
438
  overlayPromise.then((result) => {
439
439
  overlayOpen = false;
440
- // Session already handles cleanup via finishWith* methods
441
- // This just ensures the promise doesn't cause unhandled rejection
442
- if (result.userTookOver) {
443
- // User took over - session continues interactively
440
+
441
+ // Handle Ctrl+T transfer: send output back to main agent
442
+ if (result.transferred) {
443
+ const truncatedNote = result.transferred.truncated
444
+ ? ` (truncated from ${result.transferred.totalLines} total lines)`
445
+ : "";
446
+ const content = `Session ${generatedSessionId} output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
447
+
448
+ // Send message with triggerTurn to wake the agent
449
+ pi.sendMessage({
450
+ customType: "interactive-shell-transfer",
451
+ content,
452
+ display: true,
453
+ details: {
454
+ sessionId: generatedSessionId,
455
+ transferred: result.transferred,
456
+ exitCode: result.exitCode,
457
+ signal: result.signal,
458
+ },
459
+ }, { triggerTurn: true });
460
+
461
+ // Emit event for extensions that want to handle transfers
462
+ pi.events.emit("interactive-shell:transfer", {
463
+ sessionId: generatedSessionId,
464
+ transferred: result.transferred,
465
+ exitCode: result.exitCode,
466
+ signal: result.signal,
467
+ });
468
+
469
+ // Unregister session - PTY is disposed, agent has the output via sendMessage
470
+ sessionManager.unregisterActive(generatedSessionId, true);
444
471
  }
445
472
  }).catch(() => {
446
473
  overlayOpen = false;
@@ -43,7 +43,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
43
43
  private sessionUnregistered = false;
44
44
  // Timeout
45
45
  private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
46
- private timedOut = false;
47
46
  // Prevent double done() calls
48
47
  private finished = false;
49
48
  // Budget tracking for hands-free updates
@@ -52,7 +51,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
52
51
  private currentUpdateInterval: number;
53
52
  private currentQuietThreshold: number;
54
53
  private updateMode: "on-quiet" | "interval";
55
- private lastDataTime = 0;
56
54
  private quietTimer: ReturnType<typeof setTimeout> | null = null;
57
55
  private hasUnsentData = false;
58
56
  // Non-blocking mode: track status for agent queries
@@ -101,7 +99,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
101
99
 
102
100
  // Track activity for on-quiet mode
103
101
  if (this.state === "hands-free" && this.updateMode === "on-quiet") {
104
- this.lastDataTime = Date.now();
105
102
  this.hasUnsentData = true;
106
103
  this.resetQuietTimer();
107
104
  }
@@ -190,6 +187,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
190
187
 
191
188
  /** Get rendered terminal output (last N lines, truncated if too large) */
192
189
  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 } {
190
+ // Guard: if session is finished (PTY disposed), return empty output
191
+ // This handles race conditions where query arrives after Ctrl+T transfer
192
+ if (this.finished) {
193
+ return {
194
+ output: "",
195
+ truncated: false,
196
+ totalBytes: 0,
197
+ };
198
+ }
199
+
193
200
  // Handle legacy boolean parameter
194
201
  const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
195
202
  const skipRateLimit = opts.skipRateLimit ?? false;
@@ -357,11 +364,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
357
364
  }, 16);
358
365
  }
359
366
 
360
- /** Get the session ID */
361
- getSessionId(): string | null {
362
- return this.sessionId;
363
- }
364
-
365
367
  /** Kill the session programmatically */
366
368
  killSession(): void {
367
369
  if (!this.finished) {
@@ -616,35 +618,19 @@ export class InteractiveShellOverlay implements Component, Focusable {
616
618
  const maxLines = this.config.transferLines;
617
619
  const maxChars = this.config.transferMaxChars;
618
620
 
619
- // Use raw output stream for clean content
620
- const rawOutput = this.session.getRawStream({ stripAnsi: true });
621
- if (!rawOutput) {
622
- return { lines: [], totalLines: 0, truncated: false };
623
- }
624
-
625
- const allLines = rawOutput.split("\n");
626
- const totalLines = allLines.length;
627
-
628
- // Get last N lines, respecting maxChars
629
- let capturedLines: string[] = [];
630
- let charCount = 0;
631
- let truncated = false;
632
-
633
- for (let i = allLines.length - 1; i >= 0 && capturedLines.length < maxLines; i--) {
634
- const line = allLines[i]!;
635
- if (charCount + line.length > maxChars && capturedLines.length > 0) {
636
- truncated = true;
637
- break;
638
- }
639
- capturedLines.unshift(line);
640
- charCount += line.length + 1; // +1 for newline
641
- }
621
+ const result = this.session.getTailLines({
622
+ lines: maxLines,
623
+ ansi: false,
624
+ maxChars,
625
+ });
642
626
 
643
- if (capturedLines.length < totalLines) {
644
- truncated = true;
645
- }
627
+ const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
646
628
 
647
- return { lines: capturedLines, totalLines, truncated };
629
+ return {
630
+ lines: result.lines,
631
+ totalLines: result.totalLinesInBuffer,
632
+ truncated,
633
+ };
648
634
  }
649
635
 
650
636
  private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
@@ -655,24 +641,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
655
641
  const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
656
642
  if (lines <= 0 || maxChars <= 0) return undefined;
657
643
 
658
- // Use raw output stream instead of xterm buffer - TUI apps using alternate
659
- // screen buffer can have misleading content in getTailLines()
660
- const rawOutput = this.session.getRawStream({ stripAnsi: true });
661
- if (!rawOutput) return { type: "tail", when, lines: [] };
662
-
663
- const outputLines = rawOutput.split("\n");
664
-
665
- // Get last N lines, respecting maxChars
666
- let tail: string[] = [];
667
- let charCount = 0;
668
- for (let i = outputLines.length - 1; i >= 0 && tail.length < lines; i--) {
669
- const line = outputLines[i];
670
- if (charCount + line.length > maxChars && tail.length > 0) break;
671
- tail.unshift(line);
672
- charCount += line.length + 1; // +1 for newline
673
- }
644
+ const result = this.session.getTailLines({
645
+ lines,
646
+ ansi: false,
647
+ maxChars,
648
+ });
674
649
 
675
- return { type: "tail", when, lines: tail };
650
+ return { type: "tail", when, lines: result.lines };
676
651
  }
677
652
 
678
653
  private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined {
@@ -874,7 +849,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
874
849
  }
875
850
 
876
851
  this.stopHandsFreeUpdates();
877
- this.timedOut = true;
878
852
  const handoffPreview = this.maybeBuildHandoffPreview("timeout");
879
853
  const handoff = this.maybeWriteHandoffSnapshot("timeout");
880
854
  this.session.kill();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
5
5
  "type": "module",
6
6
  "bin": {
@@ -106,35 +106,19 @@ export class ReattachOverlay implements Component, Focusable {
106
106
  const maxLines = this.config.transferLines;
107
107
  const maxChars = this.config.transferMaxChars;
108
108
 
109
- // Use raw output stream for clean content
110
- const rawOutput = this.session.getRawStream({ stripAnsi: true });
111
- if (!rawOutput) {
112
- return { lines: [], totalLines: 0, truncated: false };
113
- }
114
-
115
- const allLines = rawOutput.split("\n");
116
- const totalLines = allLines.length;
117
-
118
- // Get last N lines, respecting maxChars
119
- let capturedLines: string[] = [];
120
- let charCount = 0;
121
- let truncated = false;
122
-
123
- for (let i = allLines.length - 1; i >= 0 && capturedLines.length < maxLines; i--) {
124
- const line = allLines[i]!;
125
- if (charCount + line.length > maxChars && capturedLines.length > 0) {
126
- truncated = true;
127
- break;
128
- }
129
- capturedLines.unshift(line);
130
- charCount += line.length + 1; // +1 for newline
131
- }
109
+ const result = this.session.getTailLines({
110
+ lines: maxLines,
111
+ ansi: false,
112
+ maxChars,
113
+ });
132
114
 
133
- if (capturedLines.length < totalLines) {
134
- truncated = true;
135
- }
115
+ const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
136
116
 
137
- return { lines: capturedLines, totalLines, truncated };
117
+ return {
118
+ lines: result.lines,
119
+ totalLines: result.totalLinesInBuffer,
120
+ truncated,
121
+ };
138
122
  }
139
123
 
140
124
  private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
@@ -143,24 +127,13 @@ export class ReattachOverlay implements Component, Focusable {
143
127
  const maxChars = this.config.handoffPreviewMaxChars;
144
128
  if (lines <= 0 || maxChars <= 0) return undefined;
145
129
 
146
- // Use raw output stream instead of xterm buffer - TUI apps using alternate
147
- // screen buffer can have misleading content in getTailLines()
148
- const rawOutput = this.session.getRawStream({ stripAnsi: true });
149
- if (!rawOutput) return { type: "tail", when, lines: [] };
150
-
151
- const outputLines = rawOutput.split("\n");
152
-
153
- // Get last N lines, respecting maxChars
154
- let tail: string[] = [];
155
- let charCount = 0;
156
- for (let i = outputLines.length - 1; i >= 0 && tail.length < lines; i--) {
157
- const line = outputLines[i];
158
- if (charCount + line.length > maxChars && tail.length > 0) break;
159
- tail.unshift(line);
160
- charCount += line.length + 1; // +1 for newline
161
- }
130
+ const result = this.session.getTailLines({
131
+ lines,
132
+ ansi: false,
133
+ maxChars,
134
+ });
162
135
 
163
- return { type: "tail", when, lines: tail };
136
+ return { type: "tail", when, lines: result.lines };
164
137
  }
165
138
 
166
139
  private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoff"] | undefined {