pi-interactive-shell 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
+ ## [0.4.0] - 2026-01-17
6
+
7
+ ### Added
8
+ - **Non-blocking hands-free mode** - Major change: `mode: "hands-free"` now returns immediately with a sessionId. The overlay opens for the user but the agent gets control back right away. Use `interactive_shell({ sessionId })` to query status/output and `interactive_shell({ sessionId, kill: true })` to end the session when done.
9
+ - **Session status queries** - Query active session with just `sessionId` to get current status and any new output since last check.
10
+ - **Kill option** - `interactive_shell({ sessionId, kill: true })` to programmatically end a session.
11
+ - **autoExitOnQuiet** option - Auto-kill session when output stops (after quietThreshold). Use `handsFree: { autoExitOnQuiet: true }` for sessions that should end when the nested agent goes quiet.
12
+ - **Output truncation** - Status queries now truncate output to 10KB (keeping the most recent content) to prevent overwhelming agent context. Truncation is indicated in the response.
13
+
14
+ ### Fixed
15
+ - **Non-blocking mode session lifecycle** - Sessions now stay registered after completion so agent can query final status. Previously, sessions were unregistered before agent could query completion result.
16
+ - **User takeover in non-blocking mode** - Agent can now see "user-takeover" status when querying. Previously, session was immediately unregistered when user took over.
17
+ - **Type mismatch in registerActive** - Fixed `getOutput` return type to match `OutputResult` interface.
18
+ - **Agent output position after buffer trim** - Fixed `agentOutputPosition` becoming stale when raw buffer is trimmed. When the 1MB buffer limit is exceeded and old content discarded, the agent query position is now clamped to prevent returning empty output or missing data.
19
+ - **killAll() map iteration** - Fixed modifying maps during iteration in `killAll()`. Now collects IDs/entries first to avoid unpredictable behavior when killing sessions triggers unregistration callbacks.
20
+ - **ActiveSessionResult type** - Fixed type mismatch where `output` field was required but never populated. Updated interface to match actual return type from `getResult()`.
21
+ - **Unbounded raw output growth** - rawOutput buffer now capped at 1MB, trimming old content to prevent memory growth in long-running sessions
22
+ - **Session ID reuse** - IDs are only released when session fully terminates, preventing reuse while session still running after takeover
23
+ - **DSR cursor responses** - Fixed stale cursor position when DSR appears mid-chunk; now processes chunks in order, writing to xterm before responding
24
+ - **Active sessions on shutdown** - Hands-free sessions are now killed on `session_shutdown`, preventing orphan processes
25
+ - **Quiet threshold timer** - Changing threshold now restarts any active quiet timer with the new value
26
+ - **Empty string input** - Now shows "(empty)" instead of blank in success message
27
+ - **Hands-free auto-close on exit** - Overlay now closes immediately when process exits in hands-free mode, returning control to the agent instead of waiting for countdown
28
+ - Handoff preview now uses raw output stream instead of xterm buffer. TUI apps using alternate screen buffer (like Codex, Claude, etc.) would show misleading/stale content in the preview.
29
+
5
30
  ## [0.3.0] - 2026-01-17
6
31
 
7
32
  ### Added
package/SKILL.md CHANGED
@@ -66,48 +66,64 @@ Agent starts working immediately, user supervises.
66
66
  interactive_shell({ command: 'pi "Review this codebase for security issues"' })
67
67
  ```
68
68
 
69
- ### Hands-Free (Foreground Subagent)
70
- Agent works autonomously, you receive periodic updates, user can take over anytime.
69
+ ### Hands-Free (Foreground Subagent) - NON-BLOCKING
70
+ Agent works autonomously, **returns immediately** with sessionId. You query for status/output and kill when done.
71
+
71
72
  ```typescript
73
+ // 1. Start session - returns immediately
72
74
  interactive_shell({
73
75
  command: 'pi "Fix all TypeScript errors in src/"',
74
76
  mode: "hands-free",
75
77
  reason: "Fixing TS errors"
76
78
  })
77
- ```
79
+ // Returns: { sessionId: "calm-reef", status: "running" }
78
80
 
79
- This is the primary pattern for **foreground subagents** - you delegate to pi (or another agent if user specifies) while monitoring progress.
81
+ // 2. Check status and get new output
82
+ interactive_shell({ sessionId: "calm-reef" })
83
+ // Returns: { status: "running", output: "...", runtime: 30000 }
80
84
 
81
- ## Hands-Free Status Updates
85
+ // 3. When you see task is complete, kill session
86
+ interactive_shell({ sessionId: "calm-reef", kill: true })
87
+ // Returns: { status: "killed", output: "final output..." }
88
+ ```
82
89
 
83
- When running in hands-free mode, `sessionId` is available immediately in the first update (before the overlay opens). Subsequent updates include:
84
- - `status: "running"` - update with **incremental output** (only new content since last update)
85
- - `status: "user-takeover"` - user typed something and took control
86
- - `status: "exited"` - process exited (final update before tool returns)
90
+ This is the primary pattern for **foreground subagents** - you delegate to pi (or another agent), query for progress, and decide when the task is done.
87
91
 
88
- Updates include budget tracking: `totalCharsSent` and `budgetExhausted` fields.
92
+ ## Hands-Free Workflow
89
93
 
90
- After takeover or exit, the tool continues blocking until the session closes.
94
+ ### Starting a Session
95
+ ```typescript
96
+ const result = interactive_shell({
97
+ command: 'codex "Review this codebase"',
98
+ mode: "hands-free"
99
+ })
100
+ // result.sessionId = "calm-reef"
101
+ // result.status = "running"
102
+ ```
91
103
 
92
- ### Update Modes
104
+ The user sees the overlay immediately. You get control back to continue working.
93
105
 
94
- **on-quiet (default)**: Updates emit after 5 seconds of output silence. Perfect for agent-to-agent delegation - you receive complete "thoughts" rather than fragments mid-stream.
106
+ ### Querying Status
107
+ ```typescript
108
+ interactive_shell({ sessionId: "calm-reef" })
109
+ ```
95
110
 
96
- **interval**: Updates emit on a fixed schedule (every 60s). Use when continuous output is expected.
111
+ Returns:
112
+ - `status`: "running" | "user-takeover" | "exited" | "killed" | "backgrounded"
113
+ - `output`: New output since last check (incremental)
114
+ - `runtime`: Time elapsed in ms
97
115
 
116
+ ### Ending a Session
98
117
  ```typescript
99
- // Default: on-quiet mode (recommended for agent delegation)
100
- interactive_shell({
101
- command: 'pi "Fix all TypeScript errors"',
102
- mode: "hands-free"
103
- })
118
+ interactive_shell({ sessionId: "calm-reef", kill: true })
119
+ ```
104
120
 
105
- // Force interval mode for continuous output
106
- interactive_shell({
107
- command: 'tail -f /var/log/app.log',
108
- mode: "hands-free",
109
- handsFree: { updateMode: "interval", updateInterval: 10000 }
110
- })
121
+ Kill when you see the task is complete in the output. Returns final status and output.
122
+
123
+ ### Sending Input
124
+ ```typescript
125
+ interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
126
+ interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
111
127
  ```
112
128
 
113
129
  ### Context Budget
package/index.ts CHANGED
@@ -294,31 +294,31 @@ The user will see the process in an overlay. They can:
294
294
  - Detach (double-Escape) to kill or run in background
295
295
  - In hands-free mode: type anything to take over control
296
296
 
297
- HANDS-FREE MODE:
298
- When mode="hands-free", you get updates when output stops (default: 5s of quiet).
299
- Updates only include NEW output since the last update, not the full tail.
300
- The user sees the overlay but you control the session. If user types anything,
301
- they automatically take over and you'll be notified.
302
-
303
- UPDATE MODES:
304
- - on-quiet (default): Emit update after 5s of no output. Perfect for agent-to-agent delegation.
305
- - interval: Emit on fixed schedule (every 60s). Use when continuous output is expected.
306
-
307
- Max interval (60s) acts as fallback in on-quiet mode for long continuous output.
308
-
309
- CONTEXT BUDGET:
310
- Updates have a total budget (default: 100KB). Once exhausted, updates still arrive
311
- but without content. You can adjust via handsFree.maxTotalChars.
312
-
313
- Use hands-free when you want to monitor a long-running agent without blocking.
297
+ HANDS-FREE MODE (NON-BLOCKING):
298
+ When mode="hands-free", the tool returns IMMEDIATELY with a sessionId.
299
+ The overlay opens for the user to watch, but you (the agent) get control back right away.
300
+
301
+ Workflow:
302
+ 1. Start session: interactive_shell({ command: 'pi "Fix bugs"', mode: "hands-free" })
303
+ -> Returns immediately with sessionId
304
+ 2. Check status/output: interactive_shell({ sessionId: "calm-reef" })
305
+ -> Returns current status and any new output since last check
306
+ 3. When task is done: interactive_shell({ sessionId: "calm-reef", kill: true })
307
+ -> Kills session and returns final output
308
+
309
+ The user sees the overlay and can:
310
+ - Watch output in real-time
311
+ - Take over by typing (you'll see "user-takeover" status on next query)
312
+ - Kill/background via double-Escape
314
313
 
315
- SENDING INPUT AND CHANGING SETTINGS:
316
- In hands-free mode, you receive a sessionId in updates. Use this to send input:
317
- - interactive_shell({ sessionId: "calm-reef", input: "/model\\n" })
318
- - interactive_shell({ sessionId: "calm-reef", input: { text: "sonnet", keys: ["down", "enter"] } })
314
+ QUERYING SESSION STATUS:
315
+ - interactive_shell({ sessionId: "calm-reef" }) - get status + new output
316
+ - interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
317
+ - interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
319
318
 
320
- Change update frequency dynamically:
321
- - interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 60000 } })
319
+ SENDING INPUT:
320
+ - interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
321
+ - interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
322
322
 
323
323
  Named keys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
324
324
  Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
@@ -347,7 +347,12 @@ Examples:
347
347
  ),
348
348
  sessionId: Type.Optional(
349
349
  Type.String({
350
- description: "Session ID to send input to an existing hands-free session",
350
+ description: "Session ID to interact with an existing hands-free session",
351
+ }),
352
+ ),
353
+ kill: Type.Optional(
354
+ Type.Boolean({
355
+ description: "Kill the session (requires sessionId). Use when task appears complete.",
351
356
  }),
352
357
  ),
353
358
  settings: Type.Optional(
@@ -427,6 +432,11 @@ Examples:
427
432
  maxTotalChars: Type.Optional(
428
433
  Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
429
434
  ),
435
+ autoExitOnQuiet: Type.Optional(
436
+ 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.",
438
+ }),
439
+ ),
430
440
  }),
431
441
  ),
432
442
  handoffPreview: Type.Optional(
@@ -456,6 +466,7 @@ Examples:
456
466
  const {
457
467
  command,
458
468
  sessionId,
469
+ kill,
459
470
  settings,
460
471
  input,
461
472
  cwd,
@@ -469,6 +480,7 @@ Examples:
469
480
  } = params as {
470
481
  command?: string;
471
482
  sessionId?: string;
483
+ kill?: boolean;
472
484
  settings?: { updateInterval?: number; quietThreshold?: number };
473
485
  input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
474
486
  cwd?: string;
@@ -481,14 +493,15 @@ Examples:
481
493
  quietThreshold?: number;
482
494
  updateMaxChars?: number;
483
495
  maxTotalChars?: number;
496
+ autoExitOnQuiet?: boolean;
484
497
  };
485
498
  handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
486
499
  handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
487
500
  timeout?: number;
488
501
  };
489
502
 
490
- // Mode 1: Interact with existing session (send input and/or change settings)
491
- if (sessionId && (input !== undefined || settings)) {
503
+ // Mode 1: Interact with existing session (query status, send input, kill, or change settings)
504
+ if (sessionId) {
492
505
  const session = sessionManager.getActive(sessionId);
493
506
  if (!session) {
494
507
  return {
@@ -498,6 +511,34 @@ Examples:
498
511
  };
499
512
  }
500
513
 
514
+ // Kill session if requested
515
+ if (kill) {
516
+ const { output, truncated, totalBytes } = session.getOutput();
517
+ const status = session.getStatus();
518
+ const runtime = session.getRuntime();
519
+ session.kill();
520
+ sessionManager.unregisterActive(sessionId, true);
521
+
522
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
523
+ return {
524
+ content: [
525
+ {
526
+ type: "text",
527
+ text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}:\n${output}` : ""}`,
528
+ },
529
+ ],
530
+ details: {
531
+ sessionId,
532
+ status: "killed",
533
+ runtime,
534
+ output,
535
+ outputTruncated: truncated,
536
+ outputTotalBytes: totalBytes,
537
+ previousStatus: status,
538
+ },
539
+ };
540
+ }
541
+
501
542
  const actions: string[] = [];
502
543
 
503
544
  // Apply settings changes
@@ -529,9 +570,11 @@ Examples:
529
570
 
530
571
  const inputDesc =
531
572
  typeof input === "string"
532
- ? input.length > 50
533
- ? `${input.slice(0, 50)}...`
534
- : input
573
+ ? input.length === 0
574
+ ? "(empty)"
575
+ : input.length > 50
576
+ ? `${input.slice(0, 50)}...`
577
+ : input
535
578
  : [
536
579
  input.text ?? "",
537
580
  input.keys ? `keys:[${input.keys.join(",")}]` : "",
@@ -544,6 +587,59 @@ Examples:
544
587
  actions.push(`sent: ${inputDesc}`);
545
588
  }
546
589
 
590
+ // If only querying status (no input, no settings, no kill)
591
+ if (actions.length === 0) {
592
+ const { output, truncated, totalBytes } = session.getOutput();
593
+ const status = session.getStatus();
594
+ const runtime = session.getRuntime();
595
+ const result = session.getResult();
596
+
597
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated to last 10KB)` : "";
598
+ const hasOutput = output.length > 0;
599
+
600
+ // Check if session completed
601
+ if (result) {
602
+ sessionManager.unregisterActive(sessionId, true);
603
+ return {
604
+ content: [
605
+ {
606
+ type: "text",
607
+ text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
608
+ },
609
+ ],
610
+ details: {
611
+ sessionId,
612
+ status,
613
+ runtime,
614
+ output,
615
+ outputTruncated: truncated,
616
+ outputTotalBytes: totalBytes,
617
+ exitCode: result.exitCode,
618
+ signal: result.signal,
619
+ backgroundId: result.backgroundId,
620
+ },
621
+ };
622
+ }
623
+
624
+ return {
625
+ content: [
626
+ {
627
+ type: "text",
628
+ text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nNew output${truncatedNote}:\n${output}` : "\n\n(no new output)"}`,
629
+ },
630
+ ],
631
+ details: {
632
+ sessionId,
633
+ status,
634
+ runtime,
635
+ output,
636
+ outputTruncated: truncated,
637
+ outputTotalBytes: totalBytes,
638
+ hasNewOutput: hasOutput,
639
+ },
640
+ };
641
+ }
642
+
547
643
  return {
548
644
  content: [{ type: "text", text: `Session ${sessionId}: ${actions.join(", ")}` }],
549
645
  details: { sessionId, actions },
@@ -556,7 +652,7 @@ Examples:
556
652
  content: [
557
653
  {
558
654
  type: "text",
559
- text: "Either 'command' (to start a session) or 'sessionId' + 'input' (to send input) is required",
655
+ text: "Either 'command' (to start a session) or 'sessionId' (to query/interact with existing session) is required",
560
656
  },
561
657
  ],
562
658
  isError: true,
@@ -576,16 +672,89 @@ Examples:
576
672
  const config = loadConfig(effectiveCwd);
577
673
  const isHandsFree = mode === "hands-free";
578
674
 
579
- // Generate sessionId early so it's available in the first update
675
+ // Generate sessionId early so it's available immediately
580
676
  const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
581
677
 
678
+ // For hands-free mode: non-blocking - return immediately with sessionId
679
+ // Agent can then query status/output via sessionId and kill when done
680
+ if (isHandsFree && generatedSessionId) {
681
+ // Start overlay but don't await - it runs in background
682
+ const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
683
+ (tui, theme, _kb, done) =>
684
+ new InteractiveShellOverlay(
685
+ tui,
686
+ theme,
687
+ {
688
+ command,
689
+ cwd: effectiveCwd,
690
+ name,
691
+ reason,
692
+ mode,
693
+ sessionId: generatedSessionId,
694
+ handsFreeUpdateMode: handsFree?.updateMode,
695
+ handsFreeUpdateInterval: handsFree?.updateInterval,
696
+ handsFreeQuietThreshold: handsFree?.quietThreshold,
697
+ handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
698
+ handsFreeMaxTotalChars: handsFree?.maxTotalChars,
699
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet,
700
+ // No onHandsFreeUpdate in non-blocking mode - agent queries directly
701
+ handoffPreviewEnabled: handoffPreview?.enabled,
702
+ handoffPreviewLines: handoffPreview?.lines,
703
+ handoffPreviewMaxChars: handoffPreview?.maxChars,
704
+ handoffSnapshotEnabled: handoffSnapshot?.enabled,
705
+ handoffSnapshotLines: handoffSnapshot?.lines,
706
+ handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
707
+ timeout,
708
+ },
709
+ config,
710
+ done,
711
+ ),
712
+ {
713
+ overlay: true,
714
+ overlayOptions: {
715
+ width: `${config.overlayWidthPercent}%`,
716
+ maxHeight: `${config.overlayHeightPercent}%`,
717
+ anchor: "center",
718
+ margin: 1,
719
+ },
720
+ },
721
+ );
722
+
723
+ // Handle overlay completion in background (cleanup when user closes)
724
+ overlayPromise.then((result) => {
725
+ // Session already handles cleanup via finishWith* methods
726
+ // This just ensures the promise doesn't cause unhandled rejection
727
+ if (result.userTookOver) {
728
+ // User took over - session continues interactively
729
+ }
730
+ }).catch(() => {
731
+ // Ignore errors - session cleanup handles this
732
+ });
733
+
734
+ // Return immediately - agent can query via sessionId
735
+ return {
736
+ content: [
737
+ {
738
+ type: "text",
739
+ text: `Session started: ${generatedSessionId}\nCommand: ${command}\n\nUse interactive_shell({ sessionId: "${generatedSessionId}" }) to check status/output.\nUse interactive_shell({ sessionId: "${generatedSessionId}", kill: true }) to end when done.`,
740
+ },
741
+ ],
742
+ details: {
743
+ sessionId: generatedSessionId,
744
+ status: "running",
745
+ command,
746
+ reason,
747
+ },
748
+ };
749
+ }
750
+
751
+ // Interactive mode: blocking - wait for overlay to close
582
752
  onUpdate?.({
583
- content: [{ type: "text", text: `Opening${isHandsFree ? " (hands-free)" : ""}: ${command}` }],
753
+ content: [{ type: "text", text: `Opening: ${command}` }],
584
754
  details: {
585
755
  exitCode: null,
586
756
  backgrounded: false,
587
757
  cancelled: false,
588
- sessionId: generatedSessionId,
589
758
  },
590
759
  });
591
760
 
@@ -606,6 +775,7 @@ Examples:
606
775
  handsFreeQuietThreshold: handsFree?.quietThreshold,
607
776
  handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
608
777
  handsFreeMaxTotalChars: handsFree?.maxTotalChars,
778
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet,
609
779
  onHandsFreeUpdate: isHandsFree
610
780
  ? (update) => {
611
781
  let statusText: string;
@@ -62,6 +62,8 @@ export interface InteractiveShellOptions {
62
62
  handsFreeUpdateMaxChars?: number;
63
63
  handsFreeMaxTotalChars?: number;
64
64
  onHandsFreeUpdate?: (update: HandsFreeUpdate) => void;
65
+ // Auto-exit when output stops (for agents that don't exit on their own)
66
+ autoExitOnQuiet?: boolean;
65
67
  // Auto-kill timeout
66
68
  timeout?: number;
67
69
  }
@@ -120,6 +122,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
120
122
  private lastDataTime = 0;
121
123
  private quietTimer: ReturnType<typeof setTimeout> | null = null;
122
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
127
+ private completionResult: InteractiveShellResult | undefined;
123
128
 
124
129
  constructor(
125
130
  tui: TUI,
@@ -169,7 +174,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
169
174
  // Stop timeout to prevent double done() call
170
175
  this.stopTimeout();
171
176
 
172
- // Send final update with any unsent data, then "exited" notification
177
+ // In hands-free mode (user hasn't taken over): send exited notification and auto-close immediately
173
178
  if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
174
179
  // Flush any pending output before sending exited notification
175
180
  if (this.hasUnsentData || this.updateMode === "interval") {
@@ -186,8 +191,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
186
191
  totalCharsSent: this.totalCharsSent,
187
192
  budgetExhausted: this.budgetExhausted,
188
193
  });
189
- this.unregisterActiveSession();
194
+ // Auto-close immediately in hands-free mode - agent should get control back
195
+ this.finishWithExit();
196
+ return;
190
197
  }
198
+
199
+ // Interactive mode (or user took over): show exit state with countdown
191
200
  this.stopHandsFreeUpdates();
192
201
  this.state = "exited";
193
202
  this.exitCountdown = this.config.exitAutoCloseDelay;
@@ -207,13 +216,19 @@ export class InteractiveShellOverlay implements Component, Focusable {
207
216
  this.state = "hands-free";
208
217
  // Use provided sessionId or generate one
209
218
  this.sessionId = options.sessionId ?? generateSessionId(options.name);
210
- sessionManager.registerActive(
211
- this.sessionId,
212
- options.command,
213
- (data) => this.session.write(data),
214
- (intervalMs) => this.setUpdateInterval(intervalMs),
215
- (thresholdMs) => this.setQuietThreshold(thresholdMs),
216
- );
219
+ sessionManager.registerActive({
220
+ id: this.sessionId,
221
+ command: options.command,
222
+ reason: options.reason,
223
+ write: (data) => this.session.write(data),
224
+ kill: () => this.killSession(),
225
+ getOutput: () => this.getOutputSinceLastCheck(),
226
+ getStatus: () => this.getSessionStatus(),
227
+ getRuntime: () => this.getRuntime(),
228
+ getResult: () => this.getCompletionResult(),
229
+ setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
230
+ setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
231
+ });
217
232
  this.startHandsFreeUpdates();
218
233
  }
219
234
 
@@ -225,6 +240,72 @@ export class InteractiveShellOverlay implements Component, Focusable {
225
240
  }
226
241
  }
227
242
 
243
+ // Public methods for non-blocking mode (agent queries)
244
+
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
+ };
269
+ }
270
+
271
+ return { output: newOutput, truncated: false, totalBytes };
272
+ }
273
+
274
+ /** Get current session status */
275
+ getSessionStatus(): "running" | "user-takeover" | "exited" | "killed" | "backgrounded" {
276
+ if (this.completionResult) {
277
+ if (this.completionResult.cancelled) return "killed";
278
+ if (this.completionResult.backgrounded) return "backgrounded";
279
+ if (this.userTookOver) return "user-takeover";
280
+ return "exited";
281
+ }
282
+ if (this.userTookOver) return "user-takeover";
283
+ if (this.state === "exited") return "exited";
284
+ return "running";
285
+ }
286
+
287
+ /** Get runtime in milliseconds */
288
+ getRuntime(): number {
289
+ return Date.now() - this.startTime;
290
+ }
291
+
292
+ /** Get completion result (if session has ended) */
293
+ getCompletionResult(): InteractiveShellResult | undefined {
294
+ return this.completionResult;
295
+ }
296
+
297
+ /** Get the session ID */
298
+ getSessionId(): string | null {
299
+ return this.sessionId;
300
+ }
301
+
302
+ /** Kill the session programmatically */
303
+ killSession(): void {
304
+ if (!this.finished) {
305
+ this.finishWithKill();
306
+ }
307
+ }
308
+
228
309
  private startExitCountdown(): void {
229
310
  this.stopCountdown();
230
311
  this.countdownInterval = setInterval(() => {
@@ -276,9 +357,34 @@ export class InteractiveShellOverlay implements Component, Focusable {
276
357
  this.stopQuietTimer();
277
358
  this.quietTimer = setTimeout(() => {
278
359
  this.quietTimer = null;
279
- if (this.state === "hands-free" && this.hasUnsentData) {
280
- this.emitHandsFreeUpdate();
281
- this.hasUnsentData = false;
360
+ if (this.state === "hands-free") {
361
+ // Auto-exit on quiet: kill session when output stops (agent likely finished task)
362
+ if (this.options.autoExitOnQuiet) {
363
+ // Emit final update with any pending output
364
+ if (this.hasUnsentData) {
365
+ this.emitHandsFreeUpdate();
366
+ this.hasUnsentData = false;
367
+ }
368
+ // Send completion notification and auto-close
369
+ if (this.options.onHandsFreeUpdate && this.sessionId) {
370
+ this.options.onHandsFreeUpdate({
371
+ status: "exited",
372
+ sessionId: this.sessionId,
373
+ runtime: Date.now() - this.startTime,
374
+ tail: [],
375
+ tailTruncated: false,
376
+ totalCharsSent: this.totalCharsSent,
377
+ budgetExhausted: this.budgetExhausted,
378
+ });
379
+ }
380
+ this.finishWithKill();
381
+ return;
382
+ }
383
+ // Normal behavior: just emit update
384
+ if (this.hasUnsentData) {
385
+ this.emitHandsFreeUpdate();
386
+ this.hasUnsentData = false;
387
+ }
282
388
  }
283
389
  }, this.currentQuietThreshold);
284
390
  }
@@ -320,6 +426,18 @@ export class InteractiveShellOverlay implements Component, Focusable {
320
426
  const clamped = Math.max(1000, Math.min(30000, thresholdMs));
321
427
  if (clamped === this.currentQuietThreshold) return;
322
428
  this.currentQuietThreshold = clamped;
429
+
430
+ // If a quiet timer is active, restart it with the new threshold
431
+ if (this.quietTimer && this.updateMode === "on-quiet") {
432
+ this.stopQuietTimer();
433
+ this.quietTimer = setTimeout(() => {
434
+ this.quietTimer = null;
435
+ if (this.hasUnsentData && !this.budgetExhausted) {
436
+ this.emitHandsFreeUpdate();
437
+ this.hasUnsentData = false;
438
+ }
439
+ }, this.currentQuietThreshold);
440
+ }
323
441
  }
324
442
 
325
443
  private stopHandsFreeUpdates(): void {
@@ -341,9 +459,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
341
459
  }
342
460
  }
343
461
 
344
- private unregisterActiveSession(): void {
462
+ private unregisterActiveSession(releaseId = false): void {
345
463
  if (this.sessionId && !this.sessionUnregistered) {
346
- sessionManager.unregisterActive(this.sessionId);
464
+ sessionManager.unregisterActive(this.sessionId, releaseId);
347
465
  this.sessionUnregistered = true;
348
466
  }
349
467
  }
@@ -410,22 +528,27 @@ export class InteractiveShellOverlay implements Component, Focusable {
410
528
  }
411
529
 
412
530
  this.stopHandsFreeUpdates();
413
- // Unregister from active sessions since user took over
414
- this.unregisterActiveSession();
415
531
  this.state = "running";
416
532
  this.userTookOver = true;
417
533
 
418
- // Notify agent that user took over
419
- this.options.onHandsFreeUpdate?.({
420
- status: "user-takeover",
421
- sessionId: this.sessionId,
422
- runtime: Date.now() - this.startTime,
423
- tail: [],
424
- tailTruncated: false,
425
- userTookOver: true,
426
- totalCharsSent: this.totalCharsSent,
427
- budgetExhausted: this.budgetExhausted,
428
- });
534
+ // Notify agent that user took over (streaming mode)
535
+ // In non-blocking mode, keep session registered so agent can query status
536
+ if (this.options.onHandsFreeUpdate) {
537
+ this.options.onHandsFreeUpdate({
538
+ status: "user-takeover",
539
+ sessionId: this.sessionId,
540
+ runtime: Date.now() - this.startTime,
541
+ tail: [],
542
+ tailTruncated: false,
543
+ userTookOver: true,
544
+ totalCharsSent: this.totalCharsSent,
545
+ budgetExhausted: this.budgetExhausted,
546
+ });
547
+ // Unregister after notification in streaming mode
548
+ this.unregisterActiveSession();
549
+ }
550
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
551
+ // so agent can query and see "user-takeover" status
429
552
 
430
553
  this.tui.requestRender();
431
554
  }
@@ -438,11 +561,20 @@ export class InteractiveShellOverlay implements Component, Focusable {
438
561
  const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
439
562
  if (lines <= 0 || maxChars <= 0) return undefined;
440
563
 
441
- const tail = this.session.getTailLines({
442
- lines,
443
- ansi: false,
444
- maxChars,
445
- });
564
+ // Use raw output stream instead of xterm buffer - TUI apps using alternate
565
+ // screen buffer can have misleading content in getTailLines()
566
+ const rawOutput = this.session.getRawStream({ stripAnsi: true });
567
+ const outputLines = rawOutput.split("\n");
568
+
569
+ // Get last N lines, respecting maxChars
570
+ let tail: string[] = [];
571
+ let charCount = 0;
572
+ for (let i = outputLines.length - 1; i >= 0 && tail.length < lines; i--) {
573
+ const line = outputLines[i];
574
+ if (charCount + line.length > maxChars && tail.length > 0) break;
575
+ tail.unshift(line);
576
+ charCount += line.length + 1; // +1 for newline
577
+ }
446
578
 
447
579
  return { type: "tail", when, lines: tail };
448
580
  }
@@ -492,11 +624,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
492
624
  this.stopCountdown();
493
625
  this.stopTimeout();
494
626
  this.stopHandsFreeUpdates();
495
- this.unregisterActiveSession();
627
+
496
628
  const handoffPreview = this.maybeBuildHandoffPreview("exit");
497
629
  const handoff = this.maybeWriteHandoffSnapshot("exit");
498
630
  this.session.dispose();
499
- this.done({
631
+ const result: InteractiveShellResult = {
500
632
  exitCode: this.session.exitCode,
501
633
  signal: this.session.signal,
502
634
  backgrounded: false,
@@ -505,7 +637,17 @@ export class InteractiveShellOverlay implements Component, Focusable {
505
637
  userTookOver: this.userTookOver,
506
638
  handoffPreview,
507
639
  handoff,
508
- });
640
+ };
641
+ this.completionResult = result;
642
+
643
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
644
+ // so agent can query completion result. Agent's query will unregister.
645
+ // In streaming mode, unregister now since agent got final update.
646
+ if (this.options.onHandsFreeUpdate) {
647
+ this.unregisterActiveSession(true);
648
+ }
649
+
650
+ this.done(result);
509
651
  }
510
652
 
511
653
  private finishWithBackground(): void {
@@ -514,11 +656,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
514
656
  this.stopCountdown();
515
657
  this.stopTimeout();
516
658
  this.stopHandsFreeUpdates();
517
- this.unregisterActiveSession();
659
+
518
660
  const handoffPreview = this.maybeBuildHandoffPreview("detach");
519
661
  const handoff = this.maybeWriteHandoffSnapshot("detach");
520
662
  const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason);
521
- this.done({
663
+ const result: InteractiveShellResult = {
522
664
  exitCode: null,
523
665
  backgrounded: true,
524
666
  backgroundId: id,
@@ -527,7 +669,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
527
669
  userTookOver: this.userTookOver,
528
670
  handoffPreview,
529
671
  handoff,
530
- });
672
+ };
673
+ this.completionResult = result;
674
+
675
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
676
+ // so agent can query completion result. Agent's query will unregister.
677
+ if (this.options.onHandsFreeUpdate) {
678
+ this.unregisterActiveSession(true);
679
+ }
680
+
681
+ this.done(result);
531
682
  }
532
683
 
533
684
  private finishWithKill(): void {
@@ -536,12 +687,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
536
687
  this.stopCountdown();
537
688
  this.stopTimeout();
538
689
  this.stopHandsFreeUpdates();
539
- this.unregisterActiveSession();
690
+
540
691
  const handoffPreview = this.maybeBuildHandoffPreview("kill");
541
692
  const handoff = this.maybeWriteHandoffSnapshot("kill");
542
693
  this.session.kill();
543
694
  this.session.dispose();
544
- this.done({
695
+ const result: InteractiveShellResult = {
545
696
  exitCode: null,
546
697
  backgrounded: false,
547
698
  cancelled: true,
@@ -549,7 +700,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
549
700
  userTookOver: this.userTookOver,
550
701
  handoffPreview,
551
702
  handoff,
552
- });
703
+ };
704
+ this.completionResult = result;
705
+
706
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
707
+ // so agent can query completion result. Agent's query will unregister.
708
+ if (this.options.onHandsFreeUpdate) {
709
+ this.unregisterActiveSession(true);
710
+ }
711
+
712
+ this.done(result);
553
713
  }
554
714
 
555
715
  private finishWithTimeout(): void {
@@ -578,13 +738,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
578
738
  }
579
739
 
580
740
  this.stopHandsFreeUpdates();
581
- this.unregisterActiveSession();
582
741
  this.timedOut = true;
583
742
  const handoffPreview = this.maybeBuildHandoffPreview("timeout");
584
743
  const handoff = this.maybeWriteHandoffSnapshot("timeout");
585
744
  this.session.kill();
586
745
  this.session.dispose();
587
- this.done({
746
+ const result: InteractiveShellResult = {
588
747
  exitCode: null,
589
748
  backgrounded: false,
590
749
  cancelled: false,
@@ -593,7 +752,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
593
752
  userTookOver: this.userTookOver,
594
753
  handoffPreview,
595
754
  handoff,
596
- });
755
+ };
756
+ this.completionResult = result;
757
+
758
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
759
+ // so agent can query completion result. Agent's query will unregister.
760
+ if (this.options.onHandsFreeUpdate) {
761
+ this.unregisterActiveSession(true);
762
+ }
763
+
764
+ this.done(result);
597
765
  }
598
766
 
599
767
  private handleDoubleEscape(): boolean {
@@ -810,7 +978,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
810
978
  this.stopTimeout();
811
979
  this.stopHandsFreeUpdates();
812
980
  // Safety cleanup in case dispose() is called without going through finishWith*
813
- this.unregisterActiveSession();
981
+ // In non-blocking mode with completion result, keep session so agent can query
982
+ if (!this.completionResult || this.options.onHandsFreeUpdate) {
983
+ this.unregisterActiveSession();
984
+ }
814
985
  }
815
986
  }
816
987
 
@@ -907,11 +1078,20 @@ export class ReattachOverlay implements Component, Focusable {
907
1078
  const maxChars = this.config.handoffPreviewMaxChars;
908
1079
  if (lines <= 0 || maxChars <= 0) return undefined;
909
1080
 
910
- const tail = this.session.getTailLines({
911
- lines,
912
- ansi: false,
913
- maxChars,
914
- });
1081
+ // Use raw output stream instead of xterm buffer - TUI apps using alternate
1082
+ // screen buffer can have misleading content in getTailLines()
1083
+ const rawOutput = this.session.getRawStream({ stripAnsi: true });
1084
+ const outputLines = rawOutput.split("\n");
1085
+
1086
+ // Get last N lines, respecting maxChars
1087
+ let tail: string[] = [];
1088
+ let charCount = 0;
1089
+ for (let i = outputLines.length - 1; i >= 0 && tail.length < lines; i--) {
1090
+ const line = outputLines[i];
1091
+ if (charCount + line.length > maxChars && tail.length > 0) break;
1092
+ tail.unshift(line);
1093
+ charCount += line.length + 1; // +1 for newline
1094
+ }
915
1095
 
916
1096
  return { type: "tail", when, lines: tail };
917
1097
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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
@@ -22,13 +22,40 @@ const CONTROL_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g;
22
22
  // DSR (Device Status Report) - cursor position query: ESC[6n or ESC[?6n
23
23
  const DSR_PATTERN = /\x1b\[\??6n/g;
24
24
 
25
- function stripDsrRequests(input: string): { cleaned: string; requests: number } {
26
- let requests = 0;
27
- const cleaned = input.replace(DSR_PATTERN, () => {
28
- requests += 1;
29
- return "";
30
- });
31
- return { cleaned, requests };
25
+ // Maximum raw output buffer size (1MB) - prevents unbounded memory growth
26
+ const MAX_RAW_OUTPUT_SIZE = 1024 * 1024;
27
+
28
+ interface DsrSplit {
29
+ segments: Array<{ text: string; dsrAfter: boolean }>;
30
+ hasDsr: boolean;
31
+ }
32
+
33
+ function splitAroundDsr(input: string): DsrSplit {
34
+ const segments: Array<{ text: string; dsrAfter: boolean }> = [];
35
+ let lastIndex = 0;
36
+ let hasDsr = false;
37
+
38
+ // Find all DSR requests and split around them
39
+ const regex = new RegExp(DSR_PATTERN.source, "g");
40
+ let match;
41
+ while ((match = regex.exec(input)) !== null) {
42
+ hasDsr = true;
43
+ // Text before this DSR
44
+ if (match.index > lastIndex) {
45
+ segments.push({ text: input.slice(lastIndex, match.index), dsrAfter: true });
46
+ } else {
47
+ // DSR at start or consecutive DSRs - add empty segment to trigger response
48
+ segments.push({ text: "", dsrAfter: true });
49
+ }
50
+ lastIndex = match.index + match[0].length;
51
+ }
52
+
53
+ // Remaining text after last DSR (or entire string if no DSR)
54
+ if (lastIndex < input.length) {
55
+ segments.push({ text: input.slice(lastIndex), dsrAfter: false });
56
+ }
57
+
58
+ return { segments, hasDsr };
32
59
  }
33
60
 
34
61
  function buildCursorPositionResponse(row = 1, col = 1): string {
@@ -202,6 +229,17 @@ export class PtyTerminalSession {
202
229
  private dataHandler: ((data: string) => void) | undefined;
203
230
  private exitHandler: ((exitCode: number, signal?: number) => void) | undefined;
204
231
 
232
+ // Trim raw output buffer if it exceeds max size
233
+ private trimRawOutputIfNeeded(): void {
234
+ if (this.rawOutput.length > MAX_RAW_OUTPUT_SIZE) {
235
+ const keepSize = Math.floor(MAX_RAW_OUTPUT_SIZE / 2);
236
+ const trimAmount = this.rawOutput.length - keepSize;
237
+ this.rawOutput = this.rawOutput.substring(trimAmount);
238
+ // Adjust stream position to account for trimmed content
239
+ this.lastStreamPosition = Math.max(0, this.lastStreamPosition - trimAmount);
240
+ }
241
+ }
242
+
205
243
  constructor(options: PtySessionOptions, events: PtySessionEvents = {}) {
206
244
  const {
207
245
  command,
@@ -245,29 +283,40 @@ export class PtyTerminalSession {
245
283
  this.ptyProcess.onData((data) => {
246
284
  // Handle DSR (Device Status Report) cursor position queries
247
285
  // TUI apps send ESC[6n or ESC[?6n expecting ESC[row;colR response
248
- const { cleaned, requests } = stripDsrRequests(data);
249
- if (requests > 0) {
250
- // Respond with cursor position (use xterm's actual cursor position)
251
- const buffer = this.xterm.buffer.active;
252
- const response = buildCursorPositionResponse(buffer.cursorY + 1, buffer.cursorX + 1);
253
- for (let i = 0; i < requests; i++) {
254
- this.ptyProcess.write(response);
286
+ // We must process in order: write text to xterm, THEN respond to DSR
287
+ const { segments, hasDsr } = splitAroundDsr(data);
288
+
289
+ if (!hasDsr) {
290
+ // Fast path: no DSR in data
291
+ this.writeQueue.enqueue(async () => {
292
+ this.rawOutput += data;
293
+ this.trimRawOutputIfNeeded();
294
+ await new Promise<void>((resolve) => {
295
+ this.xterm.write(data, () => resolve());
296
+ });
297
+ this.dataHandler?.(data);
298
+ });
299
+ } else {
300
+ // Process each segment in order, responding to DSR after writing preceding text
301
+ for (const segment of segments) {
302
+ this.writeQueue.enqueue(async () => {
303
+ if (segment.text) {
304
+ this.rawOutput += segment.text;
305
+ this.trimRawOutputIfNeeded();
306
+ await new Promise<void>((resolve) => {
307
+ this.xterm.write(segment.text, () => resolve());
308
+ });
309
+ this.dataHandler?.(segment.text);
310
+ }
311
+ // If there was a DSR after this segment, respond with current cursor position
312
+ if (segment.dsrAfter) {
313
+ const buffer = this.xterm.buffer.active;
314
+ const response = buildCursorPositionResponse(buffer.cursorY + 1, buffer.cursorX + 1);
315
+ this.ptyProcess.write(response);
316
+ }
317
+ });
255
318
  }
256
319
  }
257
-
258
- // Write cleaned data to xterm (without DSR sequences)
259
- const dataToProcess = requests > 0 ? cleaned : data;
260
-
261
- // Use write queue for ordered writes
262
- this.writeQueue.enqueue(async () => {
263
- // Track raw output for incremental streaming
264
- this.rawOutput += dataToProcess;
265
-
266
- await new Promise<void>((resolve) => {
267
- this.xterm.write(dataToProcess, () => resolve());
268
- });
269
- this.dataHandler?.(dataToProcess);
270
- });
271
320
  });
272
321
 
273
322
  this.ptyProcess.onExit(({ exitCode, signal }) => {
@@ -9,10 +9,33 @@ export interface BackgroundSession {
9
9
  startedAt: Date;
10
10
  }
11
11
 
12
+ export type ActiveSessionStatus = "running" | "user-takeover" | "exited" | "killed" | "backgrounded";
13
+
14
+ export interface ActiveSessionResult {
15
+ exitCode: number | null;
16
+ signal?: number;
17
+ backgrounded?: boolean;
18
+ backgroundId?: string;
19
+ cancelled?: boolean;
20
+ timedOut?: boolean;
21
+ }
22
+
23
+ export interface OutputResult {
24
+ output: string;
25
+ truncated: boolean;
26
+ totalBytes: number;
27
+ }
28
+
12
29
  export interface ActiveSession {
13
30
  id: string;
14
31
  command: string;
32
+ reason?: string;
15
33
  write: (data: string) => void;
34
+ kill: () => void;
35
+ getOutput: () => OutputResult; // Get output since last check (truncated if large)
36
+ getStatus: () => ActiveSessionStatus;
37
+ getRuntime: () => number;
38
+ getResult: () => ActiveSessionResult | undefined; // Available when completed
16
39
  setUpdateInterval?: (intervalMs: number) => void;
17
40
  setQuietThreshold?: (thresholdMs: number) => void;
18
41
  startedAt: Date;
@@ -100,26 +123,32 @@ export class ShellSessionManager {
100
123
  private activeSessions = new Map<string, ActiveSession>();
101
124
 
102
125
  // Active hands-free session management
103
- registerActive(
104
- id: string,
105
- command: string,
106
- write: (data: string) => void,
107
- setUpdateInterval?: (intervalMs: number) => void,
108
- setQuietThreshold?: (thresholdMs: number) => void,
109
- ): void {
110
- this.activeSessions.set(id, {
111
- id,
112
- command,
113
- write,
114
- setUpdateInterval,
115
- setQuietThreshold,
126
+ registerActive(session: {
127
+ id: string;
128
+ command: string;
129
+ reason?: string;
130
+ write: (data: string) => void;
131
+ kill: () => void;
132
+ getOutput: () => OutputResult;
133
+ getStatus: () => ActiveSessionStatus;
134
+ getRuntime: () => number;
135
+ getResult: () => ActiveSessionResult | undefined;
136
+ setUpdateInterval?: (intervalMs: number) => void;
137
+ setQuietThreshold?: (thresholdMs: number) => void;
138
+ }): void {
139
+ this.activeSessions.set(session.id, {
140
+ ...session,
116
141
  startedAt: new Date(),
117
142
  });
118
143
  }
119
144
 
120
- unregisterActive(id: string): void {
145
+ unregisterActive(id: string, releaseId = false): void {
121
146
  this.activeSessions.delete(id);
122
- releaseSessionId(id);
147
+ // Only release the ID if explicitly requested (when session fully terminates)
148
+ // This prevents ID reuse while session is still running after takeover
149
+ if (releaseId) {
150
+ releaseSessionId(id);
151
+ }
123
152
  }
124
153
 
125
154
  getActive(id: string): ActiveSession | undefined {
@@ -217,9 +246,26 @@ export class ShellSessionManager {
217
246
  }
218
247
 
219
248
  killAll(): void {
220
- for (const [id] of this.sessions) {
249
+ // Kill all background sessions
250
+ // Collect IDs first to avoid modifying map during iteration
251
+ const bgIds = Array.from(this.sessions.keys());
252
+ for (const id of bgIds) {
221
253
  this.remove(id);
222
254
  }
255
+
256
+ // Kill all active hands-free sessions
257
+ // Collect entries first since kill() may trigger unregisterActive()
258
+ const activeEntries = Array.from(this.activeSessions.entries());
259
+ for (const [id, session] of activeEntries) {
260
+ try {
261
+ session.kill();
262
+ } catch {
263
+ // Session may already be dead
264
+ }
265
+ // Release ID if not already released by kill()
266
+ releaseSessionId(id);
267
+ }
268
+ this.activeSessions.clear();
223
269
  }
224
270
  }
225
271