pi-interactive-shell 0.4.1 → 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,23 @@
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
+
5
22
  ## [0.4.1] - 2026-01-17
6
23
 
7
24
  ### Changed
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
@@ -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,8 @@ 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:** 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
+
125
127
  ### Sending Input
126
128
  ```typescript
127
129
  interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
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
@@ -319,6 +319,10 @@ QUERYING SESSION STATUS:
319
319
  IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
320
320
  The user is watching the overlay in real-time - you're just checking in periodically.
321
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
+
322
326
  SENDING INPUT:
323
327
  - interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
324
328
  - interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
@@ -437,7 +441,7 @@ Examples:
437
441
  ),
438
442
  autoExitOnQuiet: Type.Optional(
439
443
  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.",
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.",
441
445
  }),
442
446
  ),
443
447
  }),
@@ -516,7 +520,7 @@ Examples:
516
520
 
517
521
  // Kill session if requested
518
522
  if (kill) {
519
- const { output, truncated, totalBytes } = session.getOutput();
523
+ const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true for kill
520
524
  const status = session.getStatus();
521
525
  const runtime = session.getRuntime();
522
526
  session.kill();
@@ -592,16 +596,17 @@ Examples:
592
596
 
593
597
  // If only querying status (no input, no settings, no kill)
594
598
  if (actions.length === 0) {
595
- const { output, truncated, totalBytes } = session.getOutput();
596
599
  const status = session.getStatus();
597
600
  const runtime = session.getRuntime();
598
601
  const result = session.getResult();
599
602
 
600
- const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated to last 10KB)` : "";
601
- const hasOutput = output.length > 0;
602
-
603
- // 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
604
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
+
605
610
  sessionManager.unregisterActive(sessionId, true);
606
611
  return {
607
612
  content: [
@@ -624,11 +629,138 @@ Examples:
624
629
  };
625
630
  }
626
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
+
627
759
  return {
628
760
  content: [
629
761
  {
630
762
  type: "text",
631
- 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}` : ""}`,
632
764
  },
633
765
  ],
634
766
  details: {
@@ -638,7 +770,7 @@ Examples:
638
770
  output,
639
771
  outputTruncated: truncated,
640
772
  outputTotalBytes: totalBytes,
641
- hasNewOutput: hasOutput,
773
+ hasOutput,
642
774
  },
643
775
  };
644
776
  }
@@ -699,7 +831,8 @@ Examples:
699
831
  handsFreeQuietThreshold: handsFree?.quietThreshold,
700
832
  handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
701
833
  handsFreeMaxTotalChars: handsFree?.maxTotalChars,
702
- autoExitOnQuiet: handsFree?.autoExitOnQuiet,
834
+ // Default autoExitOnQuiet to true in hands-free mode
835
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
703
836
  // No onHandsFreeUpdate in non-blocking mode - agent queries directly
704
837
  handoffPreviewEnabled: handoffPreview?.enabled,
705
838
  handoffPreviewLines: handoffPreview?.lines,
@@ -124,6 +124,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
124
124
  private hasUnsentData = false;
125
125
  // Non-blocking mode: track status for agent queries
126
126
  private completionResult: InteractiveShellResult | undefined;
127
+ // Rate limiting for queries
128
+ private lastQueryTime = 0;
129
+ // Completion callbacks for waiters
130
+ private completeCallbacks: Array<() => void> = [];
127
131
 
128
132
  constructor(
129
133
  tui: TUI,
@@ -221,12 +225,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
221
225
  reason: options.reason,
222
226
  write: (data) => this.session.write(data),
223
227
  kill: () => this.killSession(),
224
- getOutput: () => this.getOutputSinceLastCheck(),
228
+ getOutput: (skipRateLimit) => this.getOutputSinceLastCheck(skipRateLimit),
225
229
  getStatus: () => this.getSessionStatus(),
226
230
  getRuntime: () => this.getRuntime(),
227
231
  getResult: () => this.getCompletionResult(),
228
232
  setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
229
233
  setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
234
+ onComplete: (callback) => this.registerCompleteCallback(callback),
230
235
  });
231
236
  this.startHandsFreeUpdates();
232
237
  }
@@ -247,7 +252,28 @@ export class InteractiveShellOverlay implements Component, Focusable {
247
252
  private static readonly MAX_STATUS_LINES = 20;
248
253
 
249
254
  /** Get rendered terminal output (last N lines, truncated if too large) */
250
- getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: number } {
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;
275
+ }
276
+
251
277
  // Use rendered terminal output instead of raw stream
252
278
  // This gives clean, readable content without TUI animation garbage
253
279
  const lines = this.session.getTailLines({
@@ -286,6 +312,28 @@ export class InteractiveShellOverlay implements Component, Focusable {
286
312
  return this.completionResult;
287
313
  }
288
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
+
289
337
  /** Get the session ID */
290
338
  getSessionId(): string | null {
291
339
  return this.sessionId;
@@ -358,9 +406,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
358
406
  this.hasUnsentData = false;
359
407
  }
360
408
  // Send completion notification and auto-close
409
+ // Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
361
410
  if (this.options.onHandsFreeUpdate && this.sessionId) {
362
411
  this.options.onHandsFreeUpdate({
363
- status: "exited",
412
+ status: "killed",
364
413
  sessionId: this.sessionId,
365
414
  runtime: Date.now() - this.startTime,
366
415
  tail: [],
@@ -631,6 +680,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
631
680
  handoff,
632
681
  };
633
682
  this.completionResult = result;
683
+ this.triggerCompleteCallbacks();
634
684
 
635
685
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
636
686
  // so agent can query completion result. Agent's query will unregister.
@@ -663,6 +713,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
663
713
  handoff,
664
714
  };
665
715
  this.completionResult = result;
716
+ this.triggerCompleteCallbacks();
666
717
 
667
718
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
668
719
  // so agent can query completion result. Agent's query will unregister.
@@ -694,6 +745,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
694
745
  handoff,
695
746
  };
696
747
  this.completionResult = result;
748
+ this.triggerCompleteCallbacks();
697
749
 
698
750
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
699
751
  // so agent can query completion result. Agent's query will unregister.
@@ -746,6 +798,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
746
798
  handoff,
747
799
  };
748
800
  this.completionResult = result;
801
+ this.triggerCompleteCallbacks();
749
802
 
750
803
  // In non-blocking mode (no onHandsFreeUpdate), keep session registered
751
804
  // so agent can query completion result. Agent's query will unregister.
@@ -970,10 +1023,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
970
1023
  this.stopTimeout();
971
1024
  this.stopHandsFreeUpdates();
972
1025
  // 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) {
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
975
1033
  this.unregisterActiveSession();
976
1034
  }
1035
+ // Non-blocking mode with completion: keep registered so agent can query
977
1036
  }
978
1037
  }
979
1038
 
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.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