pi-interactive-shell 0.4.0 → 0.4.2

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,32 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
+ ## [0.4.2] - 2026-01-17
6
+
7
+ ### Added
8
+ - **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.
9
+
10
+ ### Changed
11
+ - **autoExitOnQuiet now defaults to true** - In hands-free mode, sessions auto-kill when output stops (~5s of quiet). Set `handsFree: { autoExitOnQuiet: false }` to disable.
12
+ - **Smaller default overlay** - Height reduced from 90% to 45%. Configurable via `overlayHeightPercent` in settings (range: 20-90%).
13
+
14
+ ### Fixed
15
+ - **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.
16
+ - **scrollbackLines NaN handling** - Config now uses `clampInt` like other numeric fields, preventing NaN from breaking xterm scrollback.
17
+ - **autoExitOnQuiet status mismatch** - Now sends "killed" status (not "exited") to match `finishWithKill()` behavior.
18
+ - **hasNewOutput semantics** - Renamed to `hasOutput` since we use tail-based output, not incremental tracking.
19
+ - **dispose() orphaned sessions** - Now kills running processes before unregistering to prevent orphaned sessions.
20
+ - **killAll() premature ID release** - IDs now released via natural cleanup after process exit, not immediately after kill() call.
21
+
22
+ ## [0.4.1] - 2026-01-17
23
+
24
+ ### Changed
25
+ - **Rendered output for queries** - Status queries now return rendered terminal output (last 20 lines) instead of raw stream. This eliminates TUI animation noise (spinners, progress bars) and gives clean, readable content.
26
+ - **Reduced output size** - Max 20 lines and 5KB per query (down from 100 lines and 10KB). Queries are for checking in, not dumping full output.
27
+
28
+ ### Fixed
29
+ - **TUI noise in query output** - Raw stream captured all terminal animation (spinner text fragments like "Working", "orking", "rking"). Now uses xterm rendered buffer which shows clean final state.
30
+
5
31
  ## [0.4.0] - 2026-01-17
6
32
 
7
33
  ### Added
package/README.md CHANGED
@@ -146,7 +146,7 @@ Project: `<cwd>/.pi/interactive-shell.json`
146
146
  "doubleEscapeThreshold": 300,
147
147
  "exitAutoCloseDelay": 10,
148
148
  "overlayWidthPercent": 95,
149
- "overlayHeightPercent": 90,
149
+ "overlayHeightPercent": 45,
150
150
  "scrollbackLines": 5000,
151
151
  "ansiReemit": true,
152
152
  "handoffPreviewEnabled": true,
package/SKILL.md CHANGED
@@ -110,9 +110,11 @@ interactive_shell({ sessionId: "calm-reef" })
110
110
 
111
111
  Returns:
112
112
  - `status`: "running" | "user-takeover" | "exited" | "killed" | "backgrounded"
113
- - `output`: New output since last check (incremental)
113
+ - `output`: Last 20 lines of rendered terminal (clean, no TUI animation noise)
114
114
  - `runtime`: Time elapsed in ms
115
115
 
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
+
116
118
  ### Ending a Session
117
119
  ```typescript
118
120
  interactive_shell({ sessionId: "calm-reef", kill: true })
@@ -120,18 +122,20 @@ interactive_shell({ sessionId: "calm-reef", kill: true })
120
122
 
121
123
  Kill when you see the task is complete in the output. Returns final status and output.
122
124
 
125
+ **Auto-exit:** In hands-free mode, sessions auto-kill when output stops (after ~5 seconds of quiet). This means when an agent finishes its task, the session closes automatically. You can disable this with `handsFree: { autoExitOnQuiet: false }`.
126
+
123
127
  ### Sending Input
124
128
  ```typescript
125
129
  interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
126
130
  interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
127
131
  ```
128
132
 
129
- ### Context Budget
133
+ ### Query Output
130
134
 
131
- Updates have a **total character budget** (default: 100KB) to prevent overwhelming your context window:
132
- - Each update includes only NEW output since the last update (not full tail)
133
- - Default: 1500 chars max per update, 100KB total budget
134
- - When budget is exhausted, updates continue but without content (status-only)
135
+ Status queries return **rendered terminal output** (what's actually on screen), not raw stream:
136
+ - Last 20 lines of the terminal, clean and readable
137
+ - No TUI animation noise (spinners, progress bars, etc.)
138
+ - Max 5KB per query to keep context manageable
135
139
  - Configure via `handsFree.maxTotalChars`
136
140
 
137
141
  ```typescript
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,10 +312,17 @@ 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 + new output
315
+ - interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (last 20 lines)
316
316
  - interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
317
317
  - interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
318
318
 
319
+ IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
320
+ The user is watching the overlay in real-time - you're just checking in periodically.
321
+
322
+ RATE LIMITING:
323
+ Queries are limited to once every 60 seconds (configurable). If you query too soon,
324
+ the tool will automatically wait until the limit expires before returning.
325
+
319
326
  SENDING INPUT:
320
327
  - interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
321
328
  - interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
@@ -434,7 +441,7 @@ Examples:
434
441
  ),
435
442
  autoExitOnQuiet: Type.Optional(
436
443
  Type.Boolean({
437
- description: "Auto-kill session when output stops (after quietThreshold). Use for agents that don't exit on their own after completing a task.",
444
+ description: "Auto-kill session when output stops (after quietThreshold). Defaults to true in hands-free mode. Set to false to keep session alive indefinitely.",
438
445
  }),
439
446
  ),
440
447
  }),
@@ -513,7 +520,7 @@ Examples:
513
520
 
514
521
  // Kill session if requested
515
522
  if (kill) {
516
- const { output, truncated, totalBytes } = session.getOutput();
523
+ const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true for kill
517
524
  const status = session.getStatus();
518
525
  const runtime = session.getRuntime();
519
526
  session.kill();
@@ -589,16 +596,17 @@ Examples:
589
596
 
590
597
  // If only querying status (no input, no settings, no kill)
591
598
  if (actions.length === 0) {
592
- const { output, truncated, totalBytes } = session.getOutput();
593
599
  const status = session.getStatus();
594
600
  const runtime = session.getRuntime();
595
601
  const result = session.getResult();
596
602
 
597
- const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated to last 10KB)` : "";
598
- const hasOutput = output.length > 0;
599
-
600
- // Check if session completed
603
+ // If session completed, always allow query (no rate limiting)
604
+ // Rate limiting only applies to "checking in" on running sessions
601
605
  if (result) {
606
+ const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true
607
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
608
+ const hasOutput = output.length > 0;
609
+
602
610
  sessionManager.unregisterActive(sessionId, true);
603
611
  return {
604
612
  content: [
@@ -621,11 +629,138 @@ Examples:
621
629
  };
622
630
  }
623
631
 
632
+ // Session still running - check rate limiting
633
+ const outputResult = session.getOutput();
634
+
635
+ // If rate limited, wait until allowed then return fresh result
636
+ // Use Promise.race to detect if session completes during wait
637
+ if (outputResult.rateLimited && outputResult.waitSeconds) {
638
+ const waitMs = outputResult.waitSeconds * 1000;
639
+
640
+ // Race: rate limit timeout vs session completion
641
+ const completedEarly = await Promise.race([
642
+ new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
643
+ new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
644
+ ]);
645
+
646
+ // If session completed during wait, return result immediately
647
+ if (completedEarly) {
648
+ const earlySession = sessionManager.getActive(sessionId);
649
+ if (!earlySession) {
650
+ return {
651
+ content: [{ type: "text", text: `Session ${sessionId} ended` }],
652
+ details: { sessionId, status: "ended" },
653
+ };
654
+ }
655
+ const earlyResult = earlySession.getResult();
656
+ const { output, truncated, totalBytes } = earlySession.getOutput(true); // skipRateLimit
657
+ const earlyStatus = earlySession.getStatus();
658
+ const earlyRuntime = earlySession.getRuntime();
659
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
660
+ const hasOutput = output.length > 0;
661
+
662
+ if (earlyResult) {
663
+ sessionManager.unregisterActive(sessionId, true);
664
+ return {
665
+ content: [
666
+ {
667
+ type: "text",
668
+ text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
669
+ },
670
+ ],
671
+ details: {
672
+ sessionId,
673
+ status: earlyStatus,
674
+ runtime: earlyRuntime,
675
+ output,
676
+ outputTruncated: truncated,
677
+ outputTotalBytes: totalBytes,
678
+ exitCode: earlyResult.exitCode,
679
+ signal: earlyResult.signal,
680
+ backgroundId: earlyResult.backgroundId,
681
+ },
682
+ };
683
+ }
684
+ // Edge case: onComplete fired but no result yet (shouldn't happen)
685
+ // Return current status without unregistering
686
+ return {
687
+ content: [
688
+ {
689
+ type: "text",
690
+ text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
691
+ },
692
+ ],
693
+ details: {
694
+ sessionId,
695
+ status: earlyStatus,
696
+ runtime: earlyRuntime,
697
+ output,
698
+ outputTruncated: truncated,
699
+ outputTotalBytes: totalBytes,
700
+ hasOutput,
701
+ },
702
+ };
703
+ }
704
+ // Get fresh output after waiting
705
+ const freshOutput = session.getOutput();
706
+ const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
707
+ const hasOutput = freshOutput.output.length > 0;
708
+ const freshStatus = session.getStatus();
709
+ const freshRuntime = session.getRuntime();
710
+ const freshResult = session.getResult();
711
+
712
+ if (freshResult) {
713
+ sessionManager.unregisterActive(sessionId, true);
714
+ return {
715
+ content: [
716
+ {
717
+ type: "text",
718
+ text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
719
+ },
720
+ ],
721
+ details: {
722
+ sessionId,
723
+ status: freshStatus,
724
+ runtime: freshRuntime,
725
+ output: freshOutput.output,
726
+ outputTruncated: freshOutput.truncated,
727
+ outputTotalBytes: freshOutput.totalBytes,
728
+ exitCode: freshResult.exitCode,
729
+ signal: freshResult.signal,
730
+ backgroundId: freshResult.backgroundId,
731
+ },
732
+ };
733
+ }
734
+
735
+ return {
736
+ content: [
737
+ {
738
+ type: "text",
739
+ text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
740
+ },
741
+ ],
742
+ details: {
743
+ sessionId,
744
+ status: freshStatus,
745
+ runtime: freshRuntime,
746
+ output: freshOutput.output,
747
+ outputTruncated: freshOutput.truncated,
748
+ outputTotalBytes: freshOutput.totalBytes,
749
+ hasOutput,
750
+ },
751
+ };
752
+ }
753
+
754
+ const { output, truncated, totalBytes } = outputResult;
755
+
756
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
757
+ const hasOutput = output.length > 0;
758
+
624
759
  return {
625
760
  content: [
626
761
  {
627
762
  type: "text",
628
- text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nNew output${truncatedNote}:\n${output}` : "\n\n(no new output)"}`,
763
+ text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
629
764
  },
630
765
  ],
631
766
  details: {
@@ -635,7 +770,7 @@ Examples:
635
770
  output,
636
771
  outputTruncated: truncated,
637
772
  outputTotalBytes: totalBytes,
638
- hasNewOutput: hasOutput,
773
+ hasOutput,
639
774
  },
640
775
  };
641
776
  }
@@ -696,7 +831,8 @@ Examples:
696
831
  handsFreeQuietThreshold: handsFree?.quietThreshold,
697
832
  handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
698
833
  handsFreeMaxTotalChars: handsFree?.maxTotalChars,
699
- autoExitOnQuiet: handsFree?.autoExitOnQuiet,
834
+ // Default autoExitOnQuiet to true in hands-free mode
835
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
700
836
  // No onHandsFreeUpdate in non-blocking mode - agent queries directly
701
837
  handoffPreviewEnabled: handoffPreview?.enabled,
702
838
  handoffPreviewLines: handoffPreview?.lines,
@@ -122,9 +122,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
122
122
  private lastDataTime = 0;
123
123
  private quietTimer: ReturnType<typeof setTimeout> | null = null;
124
124
  private hasUnsentData = false;
125
- // Non-blocking mode: track status and output for agent queries
126
- private agentOutputPosition = 0; // Track last read position for incremental output
125
+ // Non-blocking mode: track status for agent queries
127
126
  private completionResult: InteractiveShellResult | undefined;
127
+ // Rate limiting for queries
128
+ private lastQueryTime = 0;
129
+ // Completion callbacks for waiters
130
+ private completeCallbacks: Array<() => void> = [];
128
131
 
129
132
  constructor(
130
133
  tui: TUI,
@@ -222,12 +225,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
222
225
  reason: options.reason,
223
226
  write: (data) => this.session.write(data),
224
227
  kill: () => this.killSession(),
225
- getOutput: () => this.getOutputSinceLastCheck(),
228
+ getOutput: (skipRateLimit) => this.getOutputSinceLastCheck(skipRateLimit),
226
229
  getStatus: () => this.getSessionStatus(),
227
230
  getRuntime: () => this.getRuntime(),
228
231
  getResult: () => this.getCompletionResult(),
229
232
  setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
230
233
  setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
234
+ onComplete: (callback) => this.registerCompleteCallback(callback),
231
235
  });
232
236
  this.startHandsFreeUpdates();
233
237
  }
@@ -242,33 +246,47 @@ export class InteractiveShellOverlay implements Component, Focusable {
242
246
 
243
247
  // Public methods for non-blocking mode (agent queries)
244
248
 
245
- // Max output per status query (10KB) - prevents overwhelming agent context
246
- private static readonly MAX_STATUS_OUTPUT = 10 * 1024;
247
-
248
- /** Get output since last check (incremental, truncated if too large) */
249
- getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: number } {
250
- const fullOutput = this.session.getRawStream({ stripAnsi: true });
251
-
252
- // Handle case where buffer was trimmed and our position is now past the end
253
- // This happens when rawOutput exceeds 1MB and older content is discarded
254
- const clampedPosition = Math.min(this.agentOutputPosition, fullOutput.length);
255
- const newOutput = fullOutput.substring(clampedPosition);
256
- const totalBytes = newOutput.length;
257
-
258
- // Always advance position (even if truncated, we don't want to re-send old data)
259
- this.agentOutputPosition = fullOutput.length;
260
-
261
- // Truncate if too large (keep the END, which is most recent/relevant)
262
- if (newOutput.length > InteractiveShellOverlay.MAX_STATUS_OUTPUT) {
263
- const truncated = newOutput.slice(-InteractiveShellOverlay.MAX_STATUS_OUTPUT);
264
- return {
265
- output: `[...${totalBytes - InteractiveShellOverlay.MAX_STATUS_OUTPUT} bytes truncated...]\n${truncated}`,
266
- truncated: true,
267
- totalBytes,
268
- };
249
+ // Max output per status query (5KB) - prevents overwhelming agent context
250
+ private static readonly MAX_STATUS_OUTPUT = 5 * 1024;
251
+ // Max lines to return per query - keep small, we're just checking in
252
+ private static readonly MAX_STATUS_LINES = 20;
253
+
254
+ /** Get rendered terminal output (last N lines, truncated if too large) */
255
+ getOutputSinceLastCheck(skipRateLimit = false): { output: string; truncated: boolean; totalBytes: number; rateLimited?: boolean; waitSeconds?: number } {
256
+ // Check rate limiting (unless skipped, e.g., for completed sessions)
257
+ if (!skipRateLimit) {
258
+ const now = Date.now();
259
+ const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
260
+ const elapsed = now - this.lastQueryTime;
261
+
262
+ if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
263
+ const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
264
+ return {
265
+ output: "",
266
+ truncated: false,
267
+ totalBytes: 0,
268
+ rateLimited: true,
269
+ waitSeconds,
270
+ };
271
+ }
272
+
273
+ // Update last query time
274
+ this.lastQueryTime = now;
269
275
  }
270
276
 
271
- return { output: newOutput, truncated: false, totalBytes };
277
+ // Use rendered terminal output instead of raw stream
278
+ // This gives clean, readable content without TUI animation garbage
279
+ const lines = this.session.getTailLines({
280
+ lines: InteractiveShellOverlay.MAX_STATUS_LINES,
281
+ ansi: false,
282
+ maxChars: InteractiveShellOverlay.MAX_STATUS_OUTPUT,
283
+ });
284
+
285
+ const output = lines.join("\n");
286
+ const totalBytes = output.length;
287
+ const truncated = lines.length >= InteractiveShellOverlay.MAX_STATUS_LINES;
288
+
289
+ return { output, truncated, totalBytes };
272
290
  }
273
291
 
274
292
  /** Get current session status */
@@ -294,6 +312,28 @@ export class InteractiveShellOverlay implements Component, Focusable {
294
312
  return this.completionResult;
295
313
  }
296
314
 
315
+ /** Register a callback to be called when session completes */
316
+ registerCompleteCallback(callback: () => void): void {
317
+ // If already completed, call immediately
318
+ if (this.completionResult) {
319
+ callback();
320
+ return;
321
+ }
322
+ this.completeCallbacks.push(callback);
323
+ }
324
+
325
+ /** Trigger all completion callbacks */
326
+ private triggerCompleteCallbacks(): void {
327
+ for (const callback of this.completeCallbacks) {
328
+ try {
329
+ callback();
330
+ } catch {
331
+ // Ignore errors in callbacks
332
+ }
333
+ }
334
+ this.completeCallbacks = [];
335
+ }
336
+
297
337
  /** Get the session ID */
298
338
  getSessionId(): string | null {
299
339
  return this.sessionId;
@@ -366,9 +406,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
366
406
  this.hasUnsentData = false;
367
407
  }
368
408
  // Send completion notification and auto-close
409
+ // Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
369
410
  if (this.options.onHandsFreeUpdate && this.sessionId) {
370
411
  this.options.onHandsFreeUpdate({
371
- status: "exited",
412
+ status: "killed",
372
413
  sessionId: this.sessionId,
373
414
  runtime: Date.now() - this.startTime,
374
415
  tail: [],
@@ -639,6 +680,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
639
680
  handoff,
640
681
  };
641
682
  this.completionResult = result;
683
+ this.triggerCompleteCallbacks();
642
684
 
643
685
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
644
686
  // so agent can query completion result. Agent's query will unregister.
@@ -671,6 +713,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
671
713
  handoff,
672
714
  };
673
715
  this.completionResult = result;
716
+ this.triggerCompleteCallbacks();
674
717
 
675
718
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
676
719
  // so agent can query completion result. Agent's query will unregister.
@@ -702,6 +745,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
702
745
  handoff,
703
746
  };
704
747
  this.completionResult = result;
748
+ this.triggerCompleteCallbacks();
705
749
 
706
750
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
707
751
  // so agent can query completion result. Agent's query will unregister.
@@ -754,6 +798,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
754
798
  handoff,
755
799
  };
756
800
  this.completionResult = result;
801
+ this.triggerCompleteCallbacks();
757
802
 
758
803
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
759
804
  // so agent can query completion result. Agent's query will unregister.
@@ -978,10 +1023,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
978
1023
  this.stopTimeout();
979
1024
  this.stopHandsFreeUpdates();
980
1025
  // Safety cleanup in case dispose() is called without going through finishWith*
981
- // In non-blocking mode with completion result, keep session so agent can query
982
- if (!this.completionResult || this.options.onHandsFreeUpdate) {
1026
+ // If session hasn't completed yet, kill it to prevent orphaned processes
1027
+ if (!this.completionResult) {
1028
+ this.session.kill();
1029
+ this.session.dispose();
1030
+ this.unregisterActiveSession();
1031
+ } else if (this.options.onHandsFreeUpdate) {
1032
+ // Streaming mode already delivered result, safe to unregister
983
1033
  this.unregisterActiveSession();
984
1034
  }
1035
+ // Non-blocking mode with completion: keep registered so agent can query
985
1036
  }
986
1037
  }
987
1038
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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": {
@@ -24,6 +24,9 @@ export interface OutputResult {
24
24
  output: string;
25
25
  truncated: boolean;
26
26
  totalBytes: number;
27
+ // Rate limiting
28
+ rateLimited?: boolean;
29
+ waitSeconds?: number;
27
30
  }
28
31
 
29
32
  export interface ActiveSession {
@@ -32,12 +35,13 @@ export interface ActiveSession {
32
35
  reason?: string;
33
36
  write: (data: string) => void;
34
37
  kill: () => void;
35
- getOutput: () => OutputResult; // Get output since last check (truncated if large)
38
+ getOutput: (skipRateLimit?: boolean) => OutputResult; // Get output since last check (truncated if large)
36
39
  getStatus: () => ActiveSessionStatus;
37
40
  getRuntime: () => number;
38
41
  getResult: () => ActiveSessionResult | undefined; // Available when completed
39
42
  setUpdateInterval?: (intervalMs: number) => void;
40
43
  setQuietThreshold?: (thresholdMs: number) => void;
44
+ onComplete: (callback: () => void) => void; // Register callback for when session completes
41
45
  startedAt: Date;
42
46
  }
43
47
 
@@ -129,12 +133,13 @@ export class ShellSessionManager {
129
133
  reason?: string;
130
134
  write: (data: string) => void;
131
135
  kill: () => void;
132
- getOutput: () => OutputResult;
136
+ getOutput: (skipRateLimit?: boolean) => OutputResult;
133
137
  getStatus: () => ActiveSessionStatus;
134
138
  getRuntime: () => number;
135
139
  getResult: () => ActiveSessionResult | undefined;
136
140
  setUpdateInterval?: (intervalMs: number) => void;
137
141
  setQuietThreshold?: (thresholdMs: number) => void;
142
+ onComplete: (callback: () => void) => void;
138
143
  }): void {
139
144
  this.activeSessions.set(session.id, {
140
145
  ...session,
@@ -259,13 +264,15 @@ export class ShellSessionManager {
259
264
  for (const [id, session] of activeEntries) {
260
265
  try {
261
266
  session.kill();
267
+ // Only release ID if kill succeeded - let natural cleanup handle failures
268
+ // The session's exit handler will call unregisterActive() which releases the ID
262
269
  } catch {
263
- // Session may already be dead
270
+ // Session may already be dead - still safe to release since no process running
271
+ releaseSessionId(id);
264
272
  }
265
- // Release ID if not already released by kill()
266
- releaseSessionId(id);
267
273
  }
268
- this.activeSessions.clear();
274
+ // Don't clear immediately - let unregisterActive() handle cleanup as sessions exit
275
+ // This prevents ID reuse while processes are still terminating
269
276
  }
270
277
  }
271
278