pi-interactive-shell 0.4.1 → 0.4.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
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
+ ## [0.4.3] - 2026-01-18
6
+
7
+ ### Added
8
+ - **Configurable output limits** - New `outputLines` and `outputMaxChars` parameters when querying sessions:
9
+ - `outputLines`: Request more lines (default: 20, max: 200)
10
+ - `outputMaxChars`: Request more content (default: 5KB, max: 50KB)
11
+ - Example: `interactive_shell({ sessionId: "calm-reef", outputLines: 50 })`
12
+ - **Escape hint feedback** - After pressing first Escape, shows "Press Escape again to detach..." in footer for 300ms
13
+
14
+ ### Fixed
15
+ - **Escape hint not showing** - Fixed bug where `clearEscapeHint()` was immediately resetting `showEscapeHint` to false after setting it to true
16
+ - **Negative output limits** - Added clamping to ensure `outputLines` and `outputMaxChars` are at least 1
17
+ - **Reduced flickering during rapid output** - Three improvements:
18
+ 1. Scroll position calculated at render time via `followBottom` flag (not on each data event)
19
+ 2. Debounced render requests (16ms) to batch rapid updates before drawing
20
+ 3. Explicit scroll-to-bottom after resize to prevent flash to top during dimension changes
21
+
22
+ ## [0.4.2] - 2026-01-17
23
+
24
+ ### Added
25
+ - **Query rate limiting** - Queries are limited to once every 60 seconds by default. If you query too soon, the tool automatically waits until the limit expires before returning (blocking behavior). Configurable via `minQueryIntervalSeconds` in settings (range: 5-300 seconds). Note: Rate limiting does not apply to completed sessions or kills - you can always query the final result immediately.
26
+
27
+ ### Changed
28
+ - **autoExitOnQuiet now defaults to true** - In hands-free mode, sessions auto-kill when output stops (~5s of quiet). Set `handsFree: { autoExitOnQuiet: false }` to disable.
29
+ - **Smaller default overlay** - Height reduced from 90% to 45%. Configurable via `overlayHeightPercent` in settings (range: 20-90%).
30
+
31
+ ### Fixed
32
+ - **Rate limit wait now interruptible** - When waiting for rate limit, the wait is interrupted immediately if the session completes (user kills, process exits, etc.). Uses Promise.race with onComplete callback instead of blocking sleep.
33
+ - **scrollbackLines NaN handling** - Config now uses `clampInt` like other numeric fields, preventing NaN from breaking xterm scrollback.
34
+ - **autoExitOnQuiet status mismatch** - Now sends "killed" status (not "exited") to match `finishWithKill()` behavior.
35
+ - **hasNewOutput semantics** - Renamed to `hasOutput` since we use tail-based output, not incremental tracking.
36
+ - **dispose() orphaned sessions** - Now kills running processes before unregistering to prevent orphaned sessions.
37
+ - **killAll() premature ID release** - IDs now released via natural cleanup after process exit, not immediately after kill() call.
38
+
5
39
  ## [0.4.1] - 2026-01-17
6
40
 
7
41
  ### Changed
package/README.md CHANGED
@@ -55,6 +55,12 @@ This installs the extension to `~/.pi/agent/extensions/interactive-shell/`, runs
55
55
  - `sessionId` (string, required): session ID
56
56
  - `settings` (object): `{ updateInterval?, quietThreshold? }`
57
57
 
58
+ **Query session status:**
59
+ - `sessionId` (string, required): session ID
60
+ - `outputLines` (number): lines to return (default: 20, max: 200)
61
+ - `outputMaxChars` (number): max chars to return (default: 5KB, max: 50KB)
62
+ - `kill` (boolean): kill the session and return final output
63
+
58
64
  ### Command: `/attach`
59
65
 
60
66
  Reattach to background sessions:
@@ -146,7 +152,7 @@ Project: `<cwd>/.pi/interactive-shell.json`
146
152
  "doubleEscapeThreshold": 300,
147
153
  "exitAutoCloseDelay": 10,
148
154
  "overlayWidthPercent": 95,
149
- "overlayHeightPercent": 90,
155
+ "overlayHeightPercent": 45,
150
156
  "scrollbackLines": 5000,
151
157
  "ansiReemit": true,
152
158
  "handoffPreviewEnabled": true,
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Cheat sheet + workflow for launching interactive coding-agent CLIs
5
5
 
6
6
  # Interactive Shell (Skill)
7
7
 
8
- Last verified: 2026-01-17
8
+ Last verified: 2026-01-18
9
9
 
10
10
  ## Foreground vs Background Subagents
11
11
 
@@ -113,7 +113,7 @@ Returns:
113
113
  - `output`: Last 20 lines of rendered terminal (clean, no TUI animation noise)
114
114
  - `runtime`: Time elapsed in ms
115
115
 
116
- **Don't query too frequently!** Wait 30-60 seconds between checks. The user is watching the overlay in real-time - you're just checking in periodically to see progress.
116
+ **Rate limited:** Queries are limited to once every 60 seconds. If you query too soon, the tool will automatically wait until the limit expires before returning. The user is watching the overlay in real-time - you're just checking in periodically.
117
117
 
118
118
  ### Ending a Session
119
119
  ```typescript
@@ -122,6 +122,22 @@ interactive_shell({ sessionId: "calm-reef", kill: true })
122
122
 
123
123
  Kill when you see the task is complete in the output. Returns final status and output.
124
124
 
125
+ **Auto-exit (default: enabled):** In hands-free mode, sessions auto-kill when output stops (after ~5 seconds of quiet). This means when an agent finishes its task and returns to its prompt, the session closes automatically.
126
+
127
+ Since sessions auto-close, **always instruct the subagent to save results to a file** so you can read them:
128
+
129
+ ```typescript
130
+ interactive_shell({
131
+ command: 'pi "Review this codebase for security issues. Save your findings to /tmp/security-review.md"',
132
+ mode: "hands-free",
133
+ reason: "Security review"
134
+ })
135
+ // After session ends, read the results:
136
+ // read("/tmp/security-review.md")
137
+ ```
138
+
139
+ To disable auto-exit (for long-running tasks or when you need to review output): `handsFree: { autoExitOnQuiet: false }`
140
+
125
141
  ### Sending Input
126
142
  ```typescript
127
143
  interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
@@ -131,18 +147,38 @@ interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
131
147
  ### Query Output
132
148
 
133
149
  Status queries return **rendered terminal output** (what's actually on screen), not raw stream:
134
- - Last 20 lines of the terminal, clean and readable
150
+ - Default: 20 lines, 5KB max per query
135
151
  - No TUI animation noise (spinners, progress bars, etc.)
136
- - Max 5KB per query to keep context manageable
137
- - Configure via `handsFree.maxTotalChars`
152
+ - Configurable via `outputLines` (max: 200) and `outputMaxChars` (max: 50KB)
153
+
154
+ ```typescript
155
+ // Get more output when reviewing a session
156
+ interactive_shell({ sessionId: "calm-reef", outputLines: 50 })
157
+
158
+ // Get even more for detailed review
159
+ interactive_shell({ sessionId: "calm-reef", outputLines: 100, outputMaxChars: 30000 })
160
+ ```
161
+
162
+ ### Reviewing Long Sessions (autoExitOnQuiet disabled)
163
+
164
+ When you disable auto-exit for long-running tasks, progressively review more output as needed:
138
165
 
139
166
  ```typescript
140
- // Custom budget for a long task
167
+ // Start a long session without auto-exit
141
168
  interactive_shell({
142
169
  command: 'pi "Refactor entire codebase"',
143
170
  mode: "hands-free",
144
- handsFree: { maxTotalChars: 200000 } // 200KB budget
171
+ handsFree: { autoExitOnQuiet: false }
145
172
  })
173
+
174
+ // Query returns last 20 lines by default
175
+ interactive_shell({ sessionId: "calm-reef" })
176
+
177
+ // Get more lines when you need more context
178
+ interactive_shell({ sessionId: "calm-reef", outputLines: 50 })
179
+
180
+ // Get even more for detailed review
181
+ interactive_shell({ sessionId: "calm-reef", outputLines: 100, outputMaxChars: 30000 })
146
182
  ```
147
183
 
148
184
  ## Sending Input to Active Sessions
@@ -242,49 +278,17 @@ interactive_shell({ sessionId: "calm-reef", settings: { quietThreshold: 3000 } }
242
278
  interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 30000, quietThreshold: 2000 } })
243
279
  ```
244
280
 
245
- ## CLI Cheat Sheet
246
-
247
- ### Claude Code (`claude`)
248
-
249
- | Mode | Command |
250
- |------|---------|
251
- | Interactive (idle) | `claude` |
252
- | Interactive (prompted) | `claude "Explain this project"` |
253
- | Headless (use bash, not overlay) | `claude -p "Explain this function"` |
254
-
255
- ### Gemini CLI (`gemini`)
256
-
257
- | Mode | Command |
258
- |------|---------|
259
- | Interactive (idle) | `gemini` |
260
- | Interactive (prompted) | `gemini -i "Explain this codebase"` |
261
- | Headless (use bash, not overlay) | `gemini -p "What is fine tuning?"` |
262
-
263
- ### Codex CLI (`codex`)
264
-
265
- | Mode | Command |
266
- |------|---------|
267
- | Interactive (idle) | `codex` |
268
- | Interactive (prompted) | `codex "Explain this codebase"` |
269
- | Headless (use bash, not overlay) | `codex exec "summarize the repo"` |
270
-
271
- ### Cursor CLI (`cursor-agent`)
272
-
273
- | Mode | Command |
274
- |------|---------|
275
- | Interactive (idle) | `cursor-agent` |
276
- | Interactive (prompted) | `cursor-agent "review this repo"` |
277
- | Headless (use bash, not overlay) | `cursor-agent -p "find issues" --output-format text` |
278
-
279
- ### Pi (`pi`)
281
+ ## CLI Quick Reference
280
282
 
281
- | Mode | Command |
282
- |------|---------|
283
- | Interactive (idle) | `pi` |
284
- | Interactive (prompted) | `pi "List all .ts files"` |
285
- | Headless (use bash, not overlay) | `pi -p "List all .ts files"` |
283
+ | Agent | Interactive | With Prompt | Headless (bash) |
284
+ |-------|-------------|-------------|-----------------|
285
+ | `claude` | `claude` | `claude "prompt"` | `claude -p "prompt"` |
286
+ | `gemini` | `gemini` | `gemini -i "prompt"` | `gemini "prompt"` |
287
+ | `codex` | `codex` | `codex "prompt"` | `codex exec "prompt"` |
288
+ | `agent` | `agent` | `agent "prompt"` | `agent -p "prompt"` |
289
+ | `pi` | `pi` | `pi "prompt"` | `pi -p "prompt"` |
286
290
 
287
- Note: Delegating pi to pi is recursive - usually prefer `subagent` for pi-to-pi delegation.
291
+ **Gemini model:** `gemini -m gemini-3-flash-preview -i "prompt"`
288
292
 
289
293
  ## Prompt Packaging Rules
290
294
 
package/config.ts CHANGED
@@ -21,13 +21,15 @@ export interface InteractiveShellConfig {
21
21
  handsFreeQuietThreshold: number;
22
22
  handsFreeUpdateMaxChars: number;
23
23
  handsFreeMaxTotalChars: number;
24
+ // Query rate limiting
25
+ minQueryIntervalSeconds: number;
24
26
  }
25
27
 
26
28
  const DEFAULT_CONFIG: InteractiveShellConfig = {
27
29
  doubleEscapeThreshold: 300,
28
30
  exitAutoCloseDelay: 10,
29
31
  overlayWidthPercent: 95,
30
- overlayHeightPercent: 90,
32
+ overlayHeightPercent: 45,
31
33
  scrollbackLines: 5000,
32
34
  ansiReemit: true,
33
35
  handoffPreviewEnabled: true,
@@ -42,6 +44,8 @@ const DEFAULT_CONFIG: InteractiveShellConfig = {
42
44
  handsFreeQuietThreshold: 5000,
43
45
  handsFreeUpdateMaxChars: 1500,
44
46
  handsFreeMaxTotalChars: 100000,
47
+ // Query rate limiting (default 60 seconds between queries)
48
+ minQueryIntervalSeconds: 60,
45
49
  };
46
50
 
47
51
  export function loadConfig(cwd: string): InteractiveShellConfig {
@@ -72,8 +76,9 @@ export function loadConfig(cwd: string): InteractiveShellConfig {
72
76
  return {
73
77
  ...merged,
74
78
  overlayWidthPercent: clampPercent(merged.overlayWidthPercent, DEFAULT_CONFIG.overlayWidthPercent),
75
- overlayHeightPercent: clampPercent(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent),
76
- scrollbackLines: Math.max(200, merged.scrollbackLines ?? DEFAULT_CONFIG.scrollbackLines),
79
+ // Height: 20-90% range (default 45%)
80
+ overlayHeightPercent: clampInt(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent, 20, 90),
81
+ scrollbackLines: clampInt(merged.scrollbackLines, DEFAULT_CONFIG.scrollbackLines, 200, 50000),
77
82
  ansiReemit: merged.ansiReemit !== false,
78
83
  handoffPreviewEnabled: merged.handoffPreviewEnabled !== false,
79
84
  handoffPreviewLines: clampInt(merged.handoffPreviewLines, DEFAULT_CONFIG.handoffPreviewLines, 0, 500),
@@ -117,6 +122,13 @@ export function loadConfig(cwd: string): InteractiveShellConfig {
117
122
  10000,
118
123
  1000000,
119
124
  ),
125
+ // Query rate limiting (min 5 seconds, max 300 seconds)
126
+ minQueryIntervalSeconds: clampInt(
127
+ merged.minQueryIntervalSeconds,
128
+ DEFAULT_CONFIG.minQueryIntervalSeconds,
129
+ 5,
130
+ 300,
131
+ ),
120
132
  };
121
133
  }
122
134
 
package/index.ts CHANGED
@@ -312,13 +312,19 @@ The user sees the overlay and can:
312
312
  - Kill/background via double-Escape
313
313
 
314
314
  QUERYING SESSION STATUS:
315
- - interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (last 20 lines)
315
+ - interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (default: 20 lines, 5KB)
316
+ - interactive_shell({ sessionId: "calm-reef", outputLines: 50 }) - get more lines (max: 200)
317
+ - interactive_shell({ sessionId: "calm-reef", outputMaxChars: 20000 }) - get more content (max: 50KB)
316
318
  - interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
317
319
  - interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
318
320
 
319
321
  IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
320
322
  The user is watching the overlay in real-time - you're just checking in periodically.
321
323
 
324
+ RATE LIMITING:
325
+ Queries are limited to once every 60 seconds (configurable). If you query too soon,
326
+ the tool will automatically wait until the limit expires before returning.
327
+
322
328
  SENDING INPUT:
323
329
  - interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
324
330
  - interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
@@ -358,6 +364,16 @@ Examples:
358
364
  description: "Kill the session (requires sessionId). Use when task appears complete.",
359
365
  }),
360
366
  ),
367
+ outputLines: Type.Optional(
368
+ Type.Number({
369
+ description: "Number of lines to return when querying (default: 20, max: 200)",
370
+ }),
371
+ ),
372
+ outputMaxChars: Type.Optional(
373
+ Type.Number({
374
+ description: "Max chars to return when querying (default: 5KB, max: 50KB)",
375
+ }),
376
+ ),
361
377
  settings: Type.Optional(
362
378
  Type.Object({
363
379
  updateInterval: Type.Optional(
@@ -437,7 +453,7 @@ Examples:
437
453
  ),
438
454
  autoExitOnQuiet: Type.Optional(
439
455
  Type.Boolean({
440
- description: "Auto-kill session when output stops (after quietThreshold). Use for agents that don't exit on their own after completing a task.",
456
+ description: "Auto-kill session when output stops (after quietThreshold). Defaults to true in hands-free mode. Set to false to keep session alive indefinitely.",
441
457
  }),
442
458
  ),
443
459
  }),
@@ -470,6 +486,8 @@ Examples:
470
486
  command,
471
487
  sessionId,
472
488
  kill,
489
+ outputLines,
490
+ outputMaxChars,
473
491
  settings,
474
492
  input,
475
493
  cwd,
@@ -484,6 +502,8 @@ Examples:
484
502
  command?: string;
485
503
  sessionId?: string;
486
504
  kill?: boolean;
505
+ outputLines?: number;
506
+ outputMaxChars?: number;
487
507
  settings?: { updateInterval?: number; quietThreshold?: number };
488
508
  input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
489
509
  cwd?: string;
@@ -516,7 +536,7 @@ Examples:
516
536
 
517
537
  // Kill session if requested
518
538
  if (kill) {
519
- const { output, truncated, totalBytes } = session.getOutput();
539
+ const { output, truncated, totalBytes } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
520
540
  const status = session.getStatus();
521
541
  const runtime = session.getRuntime();
522
542
  session.kill();
@@ -592,16 +612,17 @@ Examples:
592
612
 
593
613
  // If only querying status (no input, no settings, no kill)
594
614
  if (actions.length === 0) {
595
- const { output, truncated, totalBytes } = session.getOutput();
596
615
  const status = session.getStatus();
597
616
  const runtime = session.getRuntime();
598
617
  const result = session.getResult();
599
618
 
600
- const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated to last 10KB)` : "";
601
- const hasOutput = output.length > 0;
602
-
603
- // Check if session completed
619
+ // If session completed, always allow query (no rate limiting)
620
+ // Rate limiting only applies to "checking in" on running sessions
604
621
  if (result) {
622
+ const { output, truncated, totalBytes } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
623
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
624
+ const hasOutput = output.length > 0;
625
+
605
626
  sessionManager.unregisterActive(sessionId, true);
606
627
  return {
607
628
  content: [
@@ -624,11 +645,138 @@ Examples:
624
645
  };
625
646
  }
626
647
 
648
+ // Session still running - check rate limiting
649
+ const outputResult = session.getOutput({ lines: outputLines, maxChars: outputMaxChars });
650
+
651
+ // If rate limited, wait until allowed then return fresh result
652
+ // Use Promise.race to detect if session completes during wait
653
+ if (outputResult.rateLimited && outputResult.waitSeconds) {
654
+ const waitMs = outputResult.waitSeconds * 1000;
655
+
656
+ // Race: rate limit timeout vs session completion
657
+ const completedEarly = await Promise.race([
658
+ new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
659
+ new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
660
+ ]);
661
+
662
+ // If session completed during wait, return result immediately
663
+ if (completedEarly) {
664
+ const earlySession = sessionManager.getActive(sessionId);
665
+ if (!earlySession) {
666
+ return {
667
+ content: [{ type: "text", text: `Session ${sessionId} ended` }],
668
+ details: { sessionId, status: "ended" },
669
+ };
670
+ }
671
+ const earlyResult = earlySession.getResult();
672
+ const { output, truncated, totalBytes } = earlySession.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
673
+ const earlyStatus = earlySession.getStatus();
674
+ const earlyRuntime = earlySession.getRuntime();
675
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
676
+ const hasOutput = output.length > 0;
677
+
678
+ if (earlyResult) {
679
+ sessionManager.unregisterActive(sessionId, true);
680
+ return {
681
+ content: [
682
+ {
683
+ type: "text",
684
+ text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
685
+ },
686
+ ],
687
+ details: {
688
+ sessionId,
689
+ status: earlyStatus,
690
+ runtime: earlyRuntime,
691
+ output,
692
+ outputTruncated: truncated,
693
+ outputTotalBytes: totalBytes,
694
+ exitCode: earlyResult.exitCode,
695
+ signal: earlyResult.signal,
696
+ backgroundId: earlyResult.backgroundId,
697
+ },
698
+ };
699
+ }
700
+ // Edge case: onComplete fired but no result yet (shouldn't happen)
701
+ // Return current status without unregistering
702
+ return {
703
+ content: [
704
+ {
705
+ type: "text",
706
+ text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
707
+ },
708
+ ],
709
+ details: {
710
+ sessionId,
711
+ status: earlyStatus,
712
+ runtime: earlyRuntime,
713
+ output,
714
+ outputTruncated: truncated,
715
+ outputTotalBytes: totalBytes,
716
+ hasOutput,
717
+ },
718
+ };
719
+ }
720
+ // Get fresh output after waiting
721
+ const freshOutput = session.getOutput({ lines: outputLines, maxChars: outputMaxChars });
722
+ const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
723
+ const hasOutput = freshOutput.output.length > 0;
724
+ const freshStatus = session.getStatus();
725
+ const freshRuntime = session.getRuntime();
726
+ const freshResult = session.getResult();
727
+
728
+ if (freshResult) {
729
+ sessionManager.unregisterActive(sessionId, true);
730
+ return {
731
+ content: [
732
+ {
733
+ type: "text",
734
+ text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
735
+ },
736
+ ],
737
+ details: {
738
+ sessionId,
739
+ status: freshStatus,
740
+ runtime: freshRuntime,
741
+ output: freshOutput.output,
742
+ outputTruncated: freshOutput.truncated,
743
+ outputTotalBytes: freshOutput.totalBytes,
744
+ exitCode: freshResult.exitCode,
745
+ signal: freshResult.signal,
746
+ backgroundId: freshResult.backgroundId,
747
+ },
748
+ };
749
+ }
750
+
751
+ return {
752
+ content: [
753
+ {
754
+ type: "text",
755
+ text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
756
+ },
757
+ ],
758
+ details: {
759
+ sessionId,
760
+ status: freshStatus,
761
+ runtime: freshRuntime,
762
+ output: freshOutput.output,
763
+ outputTruncated: freshOutput.truncated,
764
+ outputTotalBytes: freshOutput.totalBytes,
765
+ hasOutput,
766
+ },
767
+ };
768
+ }
769
+
770
+ const { output, truncated, totalBytes } = outputResult;
771
+
772
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
773
+ const hasOutput = output.length > 0;
774
+
627
775
  return {
628
776
  content: [
629
777
  {
630
778
  type: "text",
631
- text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nNew output${truncatedNote}:\n${output}` : "\n\n(no new output)"}`,
779
+ text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
632
780
  },
633
781
  ],
634
782
  details: {
@@ -638,7 +786,7 @@ Examples:
638
786
  output,
639
787
  outputTruncated: truncated,
640
788
  outputTotalBytes: totalBytes,
641
- hasNewOutput: hasOutput,
789
+ hasOutput,
642
790
  },
643
791
  };
644
792
  }
@@ -699,7 +847,8 @@ Examples:
699
847
  handsFreeQuietThreshold: handsFree?.quietThreshold,
700
848
  handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
701
849
  handsFreeMaxTotalChars: handsFree?.maxTotalChars,
702
- autoExitOnQuiet: handsFree?.autoExitOnQuiet,
850
+ // Default autoExitOnQuiet to true in hands-free mode
851
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
703
852
  // No onHandsFreeUpdate in non-blocking mode - agent queries directly
704
853
  handoffPreviewEnabled: handoffPreview?.enabled,
705
854
  handoffPreviewLines: handoffPreview?.lines,
@@ -98,6 +98,8 @@ export class InteractiveShellOverlay implements Component, Focusable {
98
98
  private dialogSelection: DialogChoice = "background";
99
99
  private exitCountdown = 0;
100
100
  private lastEscapeTime = 0;
101
+ private showEscapeHint = false;
102
+ private escapeHintTimeout: ReturnType<typeof setTimeout> | null = null;
101
103
  private countdownInterval: ReturnType<typeof setInterval> | null = null;
102
104
  private lastWidth = 0;
103
105
  private lastHeight = 0;
@@ -124,6 +126,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
124
126
  private hasUnsentData = false;
125
127
  // Non-blocking mode: track status for agent queries
126
128
  private completionResult: InteractiveShellResult | undefined;
129
+ // Rate limiting for queries
130
+ private lastQueryTime = 0;
131
+ // Completion callbacks for waiters
132
+ private completeCallbacks: Array<() => void> = [];
133
+ // Simple render throttle to reduce flicker
134
+ private renderTimeout: ReturnType<typeof setTimeout> | null = null;
127
135
 
128
136
  constructor(
129
137
  tui: TUI,
@@ -154,10 +162,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
154
162
  },
155
163
  {
156
164
  onData: () => {
157
- if (!this.session.isScrolledUp()) {
158
- this.session.scrollToBottom();
159
- }
160
- this.tui.requestRender();
165
+ // Don't call scrollToBottom() here - pty-session handles auto-follow at render time
166
+ // Debounce render to batch rapid updates and reduce flicker
167
+ this.debouncedRender();
161
168
 
162
169
  // Track activity for on-quiet mode
163
170
  if (this.state === "hands-free" && this.updateMode === "on-quiet") {
@@ -221,12 +228,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
221
228
  reason: options.reason,
222
229
  write: (data) => this.session.write(data),
223
230
  kill: () => this.killSession(),
224
- getOutput: () => this.getOutputSinceLastCheck(),
231
+ getOutput: (options) => this.getOutputSinceLastCheck(options),
225
232
  getStatus: () => this.getSessionStatus(),
226
233
  getRuntime: () => this.getRuntime(),
227
234
  getResult: () => this.getCompletionResult(),
228
235
  setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
229
236
  setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
237
+ onComplete: (callback) => this.registerCompleteCallback(callback),
230
238
  });
231
239
  this.startHandsFreeUpdates();
232
240
  }
@@ -241,24 +249,59 @@ export class InteractiveShellOverlay implements Component, Focusable {
241
249
 
242
250
  // Public methods for non-blocking mode (agent queries)
243
251
 
244
- // Max output per status query (5KB) - prevents overwhelming agent context
245
- private static readonly MAX_STATUS_OUTPUT = 5 * 1024;
246
- // Max lines to return per query - keep small, we're just checking in
247
- private static readonly MAX_STATUS_LINES = 20;
252
+ // Default output limits per status query
253
+ private static readonly DEFAULT_STATUS_OUTPUT = 5 * 1024; // 5KB
254
+ private static readonly DEFAULT_STATUS_LINES = 20;
255
+ private static readonly MAX_STATUS_OUTPUT = 50 * 1024; // 50KB max
256
+ private static readonly MAX_STATUS_LINES = 200; // 200 lines max
248
257
 
249
258
  /** Get rendered terminal output (last N lines, truncated if too large) */
250
- getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: number } {
259
+ getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number } | boolean = false): { output: string; truncated: boolean; totalBytes: number; rateLimited?: boolean; waitSeconds?: number } {
260
+ // Handle legacy boolean parameter
261
+ const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
262
+ const skipRateLimit = opts.skipRateLimit ?? false;
263
+ // Clamp lines and maxChars to valid ranges (1 to MAX)
264
+ const requestedLines = Math.max(1, Math.min(
265
+ opts.lines ?? InteractiveShellOverlay.DEFAULT_STATUS_LINES,
266
+ InteractiveShellOverlay.MAX_STATUS_LINES
267
+ ));
268
+ const requestedMaxChars = Math.max(1, Math.min(
269
+ opts.maxChars ?? InteractiveShellOverlay.DEFAULT_STATUS_OUTPUT,
270
+ InteractiveShellOverlay.MAX_STATUS_OUTPUT
271
+ ));
272
+
273
+ // Check rate limiting (unless skipped, e.g., for completed sessions)
274
+ if (!skipRateLimit) {
275
+ const now = Date.now();
276
+ const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
277
+ const elapsed = now - this.lastQueryTime;
278
+
279
+ if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
280
+ const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
281
+ return {
282
+ output: "",
283
+ truncated: false,
284
+ totalBytes: 0,
285
+ rateLimited: true,
286
+ waitSeconds,
287
+ };
288
+ }
289
+
290
+ // Update last query time
291
+ this.lastQueryTime = now;
292
+ }
293
+
251
294
  // Use rendered terminal output instead of raw stream
252
295
  // This gives clean, readable content without TUI animation garbage
253
296
  const lines = this.session.getTailLines({
254
- lines: InteractiveShellOverlay.MAX_STATUS_LINES,
297
+ lines: requestedLines,
255
298
  ansi: false,
256
- maxChars: InteractiveShellOverlay.MAX_STATUS_OUTPUT,
299
+ maxChars: requestedMaxChars,
257
300
  });
258
301
 
259
302
  const output = lines.join("\n");
260
303
  const totalBytes = output.length;
261
- const truncated = lines.length >= InteractiveShellOverlay.MAX_STATUS_LINES;
304
+ const truncated = lines.length >= requestedLines;
262
305
 
263
306
  return { output, truncated, totalBytes };
264
307
  }
@@ -286,6 +329,40 @@ export class InteractiveShellOverlay implements Component, Focusable {
286
329
  return this.completionResult;
287
330
  }
288
331
 
332
+ /** Register a callback to be called when session completes */
333
+ registerCompleteCallback(callback: () => void): void {
334
+ // If already completed, call immediately
335
+ if (this.completionResult) {
336
+ callback();
337
+ return;
338
+ }
339
+ this.completeCallbacks.push(callback);
340
+ }
341
+
342
+ /** Trigger all completion callbacks */
343
+ private triggerCompleteCallbacks(): void {
344
+ for (const callback of this.completeCallbacks) {
345
+ try {
346
+ callback();
347
+ } catch {
348
+ // Ignore errors in callbacks
349
+ }
350
+ }
351
+ this.completeCallbacks = [];
352
+ }
353
+
354
+ /** Debounced render - waits for data to settle before rendering */
355
+ private debouncedRender(): void {
356
+ if (this.renderTimeout) {
357
+ clearTimeout(this.renderTimeout);
358
+ }
359
+ // Wait 16ms for more data before rendering
360
+ this.renderTimeout = setTimeout(() => {
361
+ this.renderTimeout = null;
362
+ this.tui.requestRender();
363
+ }, 16);
364
+ }
365
+
289
366
  /** Get the session ID */
290
367
  getSessionId(): string | null {
291
368
  return this.sessionId;
@@ -358,9 +435,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
358
435
  this.hasUnsentData = false;
359
436
  }
360
437
  // Send completion notification and auto-close
438
+ // Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
361
439
  if (this.options.onHandsFreeUpdate && this.sessionId) {
362
440
  this.options.onHandsFreeUpdate({
363
- status: "exited",
441
+ status: "killed",
364
442
  sessionId: this.sessionId,
365
443
  runtime: Date.now() - this.startTime,
366
444
  tail: [],
@@ -631,6 +709,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
631
709
  handoff,
632
710
  };
633
711
  this.completionResult = result;
712
+ this.triggerCompleteCallbacks();
634
713
 
635
714
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
636
715
  // so agent can query completion result. Agent's query will unregister.
@@ -663,6 +742,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
663
742
  handoff,
664
743
  };
665
744
  this.completionResult = result;
745
+ this.triggerCompleteCallbacks();
666
746
 
667
747
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
668
748
  // so agent can query completion result. Agent's query will unregister.
@@ -694,6 +774,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
694
774
  handoff,
695
775
  };
696
776
  this.completionResult = result;
777
+ this.triggerCompleteCallbacks();
697
778
 
698
779
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
699
780
  // so agent can query completion result. Agent's query will unregister.
@@ -746,6 +827,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
746
827
  handoff,
747
828
  };
748
829
  this.completionResult = result;
830
+ this.triggerCompleteCallbacks();
749
831
 
750
832
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
751
833
  // so agent can query completion result. Agent's query will unregister.
@@ -760,12 +842,31 @@ export class InteractiveShellOverlay implements Component, Focusable {
760
842
  const now = Date.now();
761
843
  if (now - this.lastEscapeTime < this.config.doubleEscapeThreshold) {
762
844
  this.lastEscapeTime = 0;
845
+ this.clearEscapeHint();
763
846
  return true;
764
847
  }
765
848
  this.lastEscapeTime = now;
849
+ // Show hint after first escape - clear any existing timeout first
850
+ if (this.escapeHintTimeout) {
851
+ clearTimeout(this.escapeHintTimeout);
852
+ }
853
+ this.showEscapeHint = true;
854
+ this.escapeHintTimeout = setTimeout(() => {
855
+ this.showEscapeHint = false;
856
+ this.tui.requestRender();
857
+ }, this.config.doubleEscapeThreshold);
858
+ this.tui.requestRender();
766
859
  return false;
767
860
  }
768
861
 
862
+ private clearEscapeHint(): void {
863
+ if (this.escapeHintTimeout) {
864
+ clearTimeout(this.escapeHintTimeout);
865
+ this.escapeHintTimeout = null;
866
+ }
867
+ this.showEscapeHint = false;
868
+ }
869
+
769
870
  handleInput(data: string): void {
770
871
  if (this.state === "detach-dialog") {
771
872
  this.handleDialogInput(data);
@@ -900,6 +1001,8 @@ export class InteractiveShellOverlay implements Component, Focusable {
900
1001
  this.session.resize(innerWidth, termRows);
901
1002
  this.lastWidth = innerWidth;
902
1003
  this.lastHeight = termRows;
1004
+ // After resize, ensure we're at the bottom to prevent flash to top
1005
+ this.session.scrollToBottom();
903
1006
  }
904
1007
 
905
1008
  const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
@@ -945,9 +1048,17 @@ export class InteractiveShellOverlay implements Component, Focusable {
945
1048
  footerLines.push(row(exitMsg));
946
1049
  footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
947
1050
  } else if (this.state === "hands-free") {
948
- footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Shift+Up/Down scroll")));
1051
+ if (this.showEscapeHint) {
1052
+ footerLines.push(row(warning("Press Escape again to detach...")));
1053
+ } else {
1054
+ footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Shift+Up/Down scroll")));
1055
+ }
949
1056
  } else {
950
- footerLines.push(row(dim("Shift+Up/Down scroll • Double-Esc detach • Ctrl+C interrupt")));
1057
+ if (this.showEscapeHint) {
1058
+ footerLines.push(row(warning("Press Escape again to detach...")));
1059
+ } else {
1060
+ footerLines.push(row(dim("Shift+Up/Down scroll • Double-Esc detach • Ctrl+C interrupt")));
1061
+ }
951
1062
  }
952
1063
 
953
1064
  while (footerLines.length < FOOTER_LINES) {
@@ -969,11 +1080,22 @@ export class InteractiveShellOverlay implements Component, Focusable {
969
1080
  this.stopCountdown();
970
1081
  this.stopTimeout();
971
1082
  this.stopHandsFreeUpdates();
1083
+ this.clearEscapeHint();
1084
+ if (this.renderTimeout) {
1085
+ clearTimeout(this.renderTimeout);
1086
+ this.renderTimeout = null;
1087
+ }
972
1088
  // Safety cleanup in case dispose() is called without going through finishWith*
973
- // In non-blocking mode with completion result, keep session so agent can query
974
- if (!this.completionResult || this.options.onHandsFreeUpdate) {
1089
+ // If session hasn't completed yet, kill it to prevent orphaned processes
1090
+ if (!this.completionResult) {
1091
+ this.session.kill();
1092
+ this.session.dispose();
1093
+ this.unregisterActiveSession();
1094
+ } else if (this.options.onHandsFreeUpdate) {
1095
+ // Streaming mode already delivered result, safe to unregister
975
1096
  this.unregisterActiveSession();
976
1097
  }
1098
+ // Non-blocking mode with completion: keep registered so agent can query
977
1099
  }
978
1100
  }
979
1101
 
@@ -1309,6 +1431,8 @@ export class ReattachOverlay implements Component, Focusable {
1309
1431
  this.session.resize(innerWidth, termRows);
1310
1432
  this.lastWidth = innerWidth;
1311
1433
  this.lastHeight = termRows;
1434
+ // After resize, ensure we're at the bottom to prevent flash to top
1435
+ this.session.scrollToBottom();
1312
1436
  }
1313
1437
 
1314
1438
  const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.4.1",
3
+ "version": "0.4.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": {
package/pty-session.ts CHANGED
@@ -218,6 +218,7 @@ export class PtyTerminalSession {
218
218
  private _exitCode: number | null = null;
219
219
  private _signal: number | undefined;
220
220
  private scrollOffset = 0;
221
+ private followBottom = true; // Auto-scroll to bottom when new data arrives
221
222
 
222
223
  // Raw output buffer for incremental streaming
223
224
  private rawOutput = "";
@@ -458,6 +459,11 @@ export class PtyTerminalSession {
458
459
  const lines: string[] = [];
459
460
 
460
461
  const totalLines = buffer.length;
462
+ // If following bottom, reset scroll offset at render time (not on each data event)
463
+ // This prevents flickering from scroll position racing with buffer updates
464
+ if (this.followBottom) {
465
+ this.scrollOffset = 0;
466
+ }
461
467
  const viewportStart = Math.max(0, totalLines - this.xterm.rows - this.scrollOffset);
462
468
 
463
469
  const useAnsi = !!options.ansi;
@@ -565,14 +571,20 @@ export class PtyTerminalSession {
565
571
  const buffer = this.xterm.buffer.active;
566
572
  const maxScroll = Math.max(0, buffer.length - this.xterm.rows);
567
573
  this.scrollOffset = Math.min(this.scrollOffset + lines, maxScroll);
574
+ this.followBottom = false; // User scrolled up, stop auto-following
568
575
  }
569
576
 
570
577
  scrollDown(lines: number): void {
571
578
  this.scrollOffset = Math.max(0, this.scrollOffset - lines);
579
+ // If scrolled to bottom, resume auto-following
580
+ if (this.scrollOffset === 0) {
581
+ this.followBottom = true;
582
+ }
572
583
  }
573
584
 
574
585
  scrollToBottom(): void {
575
586
  this.scrollOffset = 0;
587
+ this.followBottom = true;
576
588
  }
577
589
 
578
590
  isScrolledUp(): boolean {
@@ -24,6 +24,15 @@ export interface OutputResult {
24
24
  output: string;
25
25
  truncated: boolean;
26
26
  totalBytes: number;
27
+ // Rate limiting
28
+ rateLimited?: boolean;
29
+ waitSeconds?: number;
30
+ }
31
+
32
+ export interface OutputOptions {
33
+ skipRateLimit?: boolean;
34
+ lines?: number; // Override default 20 lines
35
+ maxChars?: number; // Override default 5KB
27
36
  }
28
37
 
29
38
  export interface ActiveSession {
@@ -32,12 +41,13 @@ export interface ActiveSession {
32
41
  reason?: string;
33
42
  write: (data: string) => void;
34
43
  kill: () => void;
35
- getOutput: () => OutputResult; // Get output since last check (truncated if large)
44
+ getOutput: (options?: OutputOptions | boolean) => OutputResult; // Get output since last check (truncated if large)
36
45
  getStatus: () => ActiveSessionStatus;
37
46
  getRuntime: () => number;
38
47
  getResult: () => ActiveSessionResult | undefined; // Available when completed
39
48
  setUpdateInterval?: (intervalMs: number) => void;
40
49
  setQuietThreshold?: (thresholdMs: number) => void;
50
+ onComplete: (callback: () => void) => void; // Register callback for when session completes
41
51
  startedAt: Date;
42
52
  }
43
53
 
@@ -129,12 +139,13 @@ export class ShellSessionManager {
129
139
  reason?: string;
130
140
  write: (data: string) => void;
131
141
  kill: () => void;
132
- getOutput: () => OutputResult;
142
+ getOutput: (skipRateLimit?: boolean) => OutputResult;
133
143
  getStatus: () => ActiveSessionStatus;
134
144
  getRuntime: () => number;
135
145
  getResult: () => ActiveSessionResult | undefined;
136
146
  setUpdateInterval?: (intervalMs: number) => void;
137
147
  setQuietThreshold?: (thresholdMs: number) => void;
148
+ onComplete: (callback: () => void) => void;
138
149
  }): void {
139
150
  this.activeSessions.set(session.id, {
140
151
  ...session,
@@ -259,13 +270,15 @@ export class ShellSessionManager {
259
270
  for (const [id, session] of activeEntries) {
260
271
  try {
261
272
  session.kill();
273
+ // Only release ID if kill succeeded - let natural cleanup handle failures
274
+ // The session's exit handler will call unregisterActive() which releases the ID
262
275
  } catch {
263
- // Session may already be dead
276
+ // Session may already be dead - still safe to release since no process running
277
+ releaseSessionId(id);
264
278
  }
265
- // Release ID if not already released by kill()
266
- releaseSessionId(id);
267
279
  }
268
- this.activeSessions.clear();
280
+ // Don't clear immediately - let unregisterActive() handle cleanup as sessions exit
281
+ // This prevents ID reuse while processes are still terminating
269
282
  }
270
283
  }
271
284