pi-interactive-shell 0.3.3 → 0.4.1

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,9 +2,38 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
- ## [0.3.3] - 2026-01-17
5
+ ## [0.4.1] - 2026-01-17
6
+
7
+ ### Changed
8
+ - **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.
9
+ - **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.
10
+
11
+ ### Fixed
12
+ - **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.
13
+
14
+ ## [0.4.0] - 2026-01-17
15
+
16
+ ### Added
17
+ - **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.
18
+ - **Session status queries** - Query active session with just `sessionId` to get current status and any new output since last check.
19
+ - **Kill option** - `interactive_shell({ sessionId, kill: true })` to programmatically end a session.
20
+ - **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.
21
+ - **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.
6
22
 
7
23
  ### Fixed
24
+ - **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.
25
+ - **User takeover in non-blocking mode** - Agent can now see "user-takeover" status when querying. Previously, session was immediately unregistered when user took over.
26
+ - **Type mismatch in registerActive** - Fixed `getOutput` return type to match `OutputResult` interface.
27
+ - **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.
28
+ - **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.
29
+ - **ActiveSessionResult type** - Fixed type mismatch where `output` field was required but never populated. Updated interface to match actual return type from `getResult()`.
30
+ - **Unbounded raw output growth** - rawOutput buffer now capped at 1MB, trimming old content to prevent memory growth in long-running sessions
31
+ - **Session ID reuse** - IDs are only released when session fully terminates, preventing reuse while session still running after takeover
32
+ - **DSR cursor responses** - Fixed stale cursor position when DSR appears mid-chunk; now processes chunks in order, writing to xterm before responding
33
+ - **Active sessions on shutdown** - Hands-free sessions are now killed on `session_shutdown`, preventing orphan processes
34
+ - **Quiet threshold timer** - Changing threshold now restarts any active quiet timer with the new value
35
+ - **Empty string input** - Now shows "(empty)" instead of blank in success message
36
+ - **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
8
37
  - 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.
9
38
 
10
39
  ## [0.3.0] - 2026-01-17
package/SKILL.md CHANGED
@@ -66,56 +66,74 @@ 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
93
+
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
+ ```
89
103
 
90
- After takeover or exit, the tool continues blocking until the session closes.
104
+ The user sees the overlay immediately. You get control back to continue working.
91
105
 
92
- ### Update Modes
106
+ ### Querying Status
107
+ ```typescript
108
+ interactive_shell({ sessionId: "calm-reef" })
109
+ ```
93
110
 
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.
111
+ Returns:
112
+ - `status`: "running" | "user-takeover" | "exited" | "killed" | "backgrounded"
113
+ - `output`: Last 20 lines of rendered terminal (clean, no TUI animation noise)
114
+ - `runtime`: Time elapsed in ms
95
115
 
96
- **interval**: Updates emit on a fixed schedule (every 60s). Use when continuous output is expected.
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.
97
117
 
118
+ ### Ending a Session
98
119
  ```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
- })
120
+ interactive_shell({ sessionId: "calm-reef", kill: true })
121
+ ```
104
122
 
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
- })
123
+ Kill when you see the task is complete in the output. Returns final status and output.
124
+
125
+ ### Sending Input
126
+ ```typescript
127
+ interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
128
+ interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
111
129
  ```
112
130
 
113
- ### Context Budget
131
+ ### Query Output
114
132
 
115
- Updates have a **total character budget** (default: 100KB) to prevent overwhelming your context window:
116
- - Each update includes only NEW output since the last update (not full tail)
117
- - Default: 1500 chars max per update, 100KB total budget
118
- - When budget is exhausted, updates continue but without content (status-only)
133
+ Status queries return **rendered terminal output** (what's actually on screen), not raw stream:
134
+ - Last 20 lines of the terminal, clean and readable
135
+ - No TUI animation noise (spinners, progress bars, etc.)
136
+ - Max 5KB per query to keep context manageable
119
137
  - Configure via `handsFree.maxTotalChars`
120
138
 
121
139
  ```typescript
package/index.ts CHANGED
@@ -294,31 +294,34 @@ 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.
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
312
313
 
313
- Use hands-free when you want to monitor a long-running agent without blocking.
314
+ QUERYING SESSION STATUS:
315
+ - interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (last 20 lines)
316
+ - interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
317
+ - interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
314
318
 
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"] } })
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.
319
321
 
320
- Change update frequency dynamically:
321
- - interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 60000 } })
322
+ SENDING INPUT:
323
+ - interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
324
+ - interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
322
325
 
323
326
  Named keys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
324
327
  Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
@@ -347,7 +350,12 @@ Examples:
347
350
  ),
348
351
  sessionId: Type.Optional(
349
352
  Type.String({
350
- description: "Session ID to send input to an existing hands-free session",
353
+ description: "Session ID to interact with an existing hands-free session",
354
+ }),
355
+ ),
356
+ kill: Type.Optional(
357
+ Type.Boolean({
358
+ description: "Kill the session (requires sessionId). Use when task appears complete.",
351
359
  }),
352
360
  ),
353
361
  settings: Type.Optional(
@@ -427,6 +435,11 @@ Examples:
427
435
  maxTotalChars: Type.Optional(
428
436
  Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
429
437
  ),
438
+ autoExitOnQuiet: Type.Optional(
439
+ 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.",
441
+ }),
442
+ ),
430
443
  }),
431
444
  ),
432
445
  handoffPreview: Type.Optional(
@@ -456,6 +469,7 @@ Examples:
456
469
  const {
457
470
  command,
458
471
  sessionId,
472
+ kill,
459
473
  settings,
460
474
  input,
461
475
  cwd,
@@ -469,6 +483,7 @@ Examples:
469
483
  } = params as {
470
484
  command?: string;
471
485
  sessionId?: string;
486
+ kill?: boolean;
472
487
  settings?: { updateInterval?: number; quietThreshold?: number };
473
488
  input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
474
489
  cwd?: string;
@@ -481,14 +496,15 @@ Examples:
481
496
  quietThreshold?: number;
482
497
  updateMaxChars?: number;
483
498
  maxTotalChars?: number;
499
+ autoExitOnQuiet?: boolean;
484
500
  };
485
501
  handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
486
502
  handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
487
503
  timeout?: number;
488
504
  };
489
505
 
490
- // Mode 1: Interact with existing session (send input and/or change settings)
491
- if (sessionId && (input !== undefined || settings)) {
506
+ // Mode 1: Interact with existing session (query status, send input, kill, or change settings)
507
+ if (sessionId) {
492
508
  const session = sessionManager.getActive(sessionId);
493
509
  if (!session) {
494
510
  return {
@@ -498,6 +514,34 @@ Examples:
498
514
  };
499
515
  }
500
516
 
517
+ // Kill session if requested
518
+ if (kill) {
519
+ const { output, truncated, totalBytes } = session.getOutput();
520
+ const status = session.getStatus();
521
+ const runtime = session.getRuntime();
522
+ session.kill();
523
+ sessionManager.unregisterActive(sessionId, true);
524
+
525
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
526
+ return {
527
+ content: [
528
+ {
529
+ type: "text",
530
+ text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}:\n${output}` : ""}`,
531
+ },
532
+ ],
533
+ details: {
534
+ sessionId,
535
+ status: "killed",
536
+ runtime,
537
+ output,
538
+ outputTruncated: truncated,
539
+ outputTotalBytes: totalBytes,
540
+ previousStatus: status,
541
+ },
542
+ };
543
+ }
544
+
501
545
  const actions: string[] = [];
502
546
 
503
547
  // Apply settings changes
@@ -529,9 +573,11 @@ Examples:
529
573
 
530
574
  const inputDesc =
531
575
  typeof input === "string"
532
- ? input.length > 50
533
- ? `${input.slice(0, 50)}...`
534
- : input
576
+ ? input.length === 0
577
+ ? "(empty)"
578
+ : input.length > 50
579
+ ? `${input.slice(0, 50)}...`
580
+ : input
535
581
  : [
536
582
  input.text ?? "",
537
583
  input.keys ? `keys:[${input.keys.join(",")}]` : "",
@@ -544,6 +590,59 @@ Examples:
544
590
  actions.push(`sent: ${inputDesc}`);
545
591
  }
546
592
 
593
+ // If only querying status (no input, no settings, no kill)
594
+ if (actions.length === 0) {
595
+ const { output, truncated, totalBytes } = session.getOutput();
596
+ const status = session.getStatus();
597
+ const runtime = session.getRuntime();
598
+ const result = session.getResult();
599
+
600
+ const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated to last 10KB)` : "";
601
+ const hasOutput = output.length > 0;
602
+
603
+ // Check if session completed
604
+ if (result) {
605
+ sessionManager.unregisterActive(sessionId, true);
606
+ return {
607
+ content: [
608
+ {
609
+ type: "text",
610
+ text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
611
+ },
612
+ ],
613
+ details: {
614
+ sessionId,
615
+ status,
616
+ runtime,
617
+ output,
618
+ outputTruncated: truncated,
619
+ outputTotalBytes: totalBytes,
620
+ exitCode: result.exitCode,
621
+ signal: result.signal,
622
+ backgroundId: result.backgroundId,
623
+ },
624
+ };
625
+ }
626
+
627
+ return {
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nNew output${truncatedNote}:\n${output}` : "\n\n(no new output)"}`,
632
+ },
633
+ ],
634
+ details: {
635
+ sessionId,
636
+ status,
637
+ runtime,
638
+ output,
639
+ outputTruncated: truncated,
640
+ outputTotalBytes: totalBytes,
641
+ hasNewOutput: hasOutput,
642
+ },
643
+ };
644
+ }
645
+
547
646
  return {
548
647
  content: [{ type: "text", text: `Session ${sessionId}: ${actions.join(", ")}` }],
549
648
  details: { sessionId, actions },
@@ -556,7 +655,7 @@ Examples:
556
655
  content: [
557
656
  {
558
657
  type: "text",
559
- text: "Either 'command' (to start a session) or 'sessionId' + 'input' (to send input) is required",
658
+ text: "Either 'command' (to start a session) or 'sessionId' (to query/interact with existing session) is required",
560
659
  },
561
660
  ],
562
661
  isError: true,
@@ -576,16 +675,89 @@ Examples:
576
675
  const config = loadConfig(effectiveCwd);
577
676
  const isHandsFree = mode === "hands-free";
578
677
 
579
- // Generate sessionId early so it's available in the first update
678
+ // Generate sessionId early so it's available immediately
580
679
  const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
581
680
 
681
+ // For hands-free mode: non-blocking - return immediately with sessionId
682
+ // Agent can then query status/output via sessionId and kill when done
683
+ if (isHandsFree && generatedSessionId) {
684
+ // Start overlay but don't await - it runs in background
685
+ const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
686
+ (tui, theme, _kb, done) =>
687
+ new InteractiveShellOverlay(
688
+ tui,
689
+ theme,
690
+ {
691
+ command,
692
+ cwd: effectiveCwd,
693
+ name,
694
+ reason,
695
+ mode,
696
+ sessionId: generatedSessionId,
697
+ handsFreeUpdateMode: handsFree?.updateMode,
698
+ handsFreeUpdateInterval: handsFree?.updateInterval,
699
+ handsFreeQuietThreshold: handsFree?.quietThreshold,
700
+ handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
701
+ handsFreeMaxTotalChars: handsFree?.maxTotalChars,
702
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet,
703
+ // No onHandsFreeUpdate in non-blocking mode - agent queries directly
704
+ handoffPreviewEnabled: handoffPreview?.enabled,
705
+ handoffPreviewLines: handoffPreview?.lines,
706
+ handoffPreviewMaxChars: handoffPreview?.maxChars,
707
+ handoffSnapshotEnabled: handoffSnapshot?.enabled,
708
+ handoffSnapshotLines: handoffSnapshot?.lines,
709
+ handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
710
+ timeout,
711
+ },
712
+ config,
713
+ done,
714
+ ),
715
+ {
716
+ overlay: true,
717
+ overlayOptions: {
718
+ width: `${config.overlayWidthPercent}%`,
719
+ maxHeight: `${config.overlayHeightPercent}%`,
720
+ anchor: "center",
721
+ margin: 1,
722
+ },
723
+ },
724
+ );
725
+
726
+ // Handle overlay completion in background (cleanup when user closes)
727
+ overlayPromise.then((result) => {
728
+ // Session already handles cleanup via finishWith* methods
729
+ // This just ensures the promise doesn't cause unhandled rejection
730
+ if (result.userTookOver) {
731
+ // User took over - session continues interactively
732
+ }
733
+ }).catch(() => {
734
+ // Ignore errors - session cleanup handles this
735
+ });
736
+
737
+ // Return immediately - agent can query via sessionId
738
+ return {
739
+ content: [
740
+ {
741
+ type: "text",
742
+ 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.`,
743
+ },
744
+ ],
745
+ details: {
746
+ sessionId: generatedSessionId,
747
+ status: "running",
748
+ command,
749
+ reason,
750
+ },
751
+ };
752
+ }
753
+
754
+ // Interactive mode: blocking - wait for overlay to close
582
755
  onUpdate?.({
583
- content: [{ type: "text", text: `Opening${isHandsFree ? " (hands-free)" : ""}: ${command}` }],
756
+ content: [{ type: "text", text: `Opening: ${command}` }],
584
757
  details: {
585
758
  exitCode: null,
586
759
  backgrounded: false,
587
760
  cancelled: false,
588
- sessionId: generatedSessionId,
589
761
  },
590
762
  });
591
763
 
@@ -606,6 +778,7 @@ Examples:
606
778
  handsFreeQuietThreshold: handsFree?.quietThreshold,
607
779
  handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
608
780
  handsFreeMaxTotalChars: handsFree?.maxTotalChars,
781
+ autoExitOnQuiet: handsFree?.autoExitOnQuiet,
609
782
  onHandsFreeUpdate: isHandsFree
610
783
  ? (update) => {
611
784
  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,8 @@ 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 for agent queries
126
+ private completionResult: InteractiveShellResult | undefined;
123
127
 
124
128
  constructor(
125
129
  tui: TUI,
@@ -169,7 +173,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
169
173
  // Stop timeout to prevent double done() call
170
174
  this.stopTimeout();
171
175
 
172
- // Send final update with any unsent data, then "exited" notification
176
+ // In hands-free mode (user hasn't taken over): send exited notification and auto-close immediately
173
177
  if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
174
178
  // Flush any pending output before sending exited notification
175
179
  if (this.hasUnsentData || this.updateMode === "interval") {
@@ -186,8 +190,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
186
190
  totalCharsSent: this.totalCharsSent,
187
191
  budgetExhausted: this.budgetExhausted,
188
192
  });
189
- this.unregisterActiveSession();
193
+ // Auto-close immediately in hands-free mode - agent should get control back
194
+ this.finishWithExit();
195
+ return;
190
196
  }
197
+
198
+ // Interactive mode (or user took over): show exit state with countdown
191
199
  this.stopHandsFreeUpdates();
192
200
  this.state = "exited";
193
201
  this.exitCountdown = this.config.exitAutoCloseDelay;
@@ -207,13 +215,19 @@ export class InteractiveShellOverlay implements Component, Focusable {
207
215
  this.state = "hands-free";
208
216
  // Use provided sessionId or generate one
209
217
  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
- );
218
+ sessionManager.registerActive({
219
+ id: this.sessionId,
220
+ command: options.command,
221
+ reason: options.reason,
222
+ write: (data) => this.session.write(data),
223
+ kill: () => this.killSession(),
224
+ getOutput: () => this.getOutputSinceLastCheck(),
225
+ getStatus: () => this.getSessionStatus(),
226
+ getRuntime: () => this.getRuntime(),
227
+ getResult: () => this.getCompletionResult(),
228
+ setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
229
+ setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
230
+ });
217
231
  this.startHandsFreeUpdates();
218
232
  }
219
233
 
@@ -225,6 +239,65 @@ export class InteractiveShellOverlay implements Component, Focusable {
225
239
  }
226
240
  }
227
241
 
242
+ // Public methods for non-blocking mode (agent queries)
243
+
244
+ // Max output per status query (5KB) - prevents overwhelming agent context
245
+ private static readonly MAX_STATUS_OUTPUT = 5 * 1024;
246
+ // Max lines to return per query - keep small, we're just checking in
247
+ private static readonly MAX_STATUS_LINES = 20;
248
+
249
+ /** Get rendered terminal output (last N lines, truncated if too large) */
250
+ getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: number } {
251
+ // Use rendered terminal output instead of raw stream
252
+ // This gives clean, readable content without TUI animation garbage
253
+ const lines = this.session.getTailLines({
254
+ lines: InteractiveShellOverlay.MAX_STATUS_LINES,
255
+ ansi: false,
256
+ maxChars: InteractiveShellOverlay.MAX_STATUS_OUTPUT,
257
+ });
258
+
259
+ const output = lines.join("\n");
260
+ const totalBytes = output.length;
261
+ const truncated = lines.length >= InteractiveShellOverlay.MAX_STATUS_LINES;
262
+
263
+ return { output, truncated, totalBytes };
264
+ }
265
+
266
+ /** Get current session status */
267
+ getSessionStatus(): "running" | "user-takeover" | "exited" | "killed" | "backgrounded" {
268
+ if (this.completionResult) {
269
+ if (this.completionResult.cancelled) return "killed";
270
+ if (this.completionResult.backgrounded) return "backgrounded";
271
+ if (this.userTookOver) return "user-takeover";
272
+ return "exited";
273
+ }
274
+ if (this.userTookOver) return "user-takeover";
275
+ if (this.state === "exited") return "exited";
276
+ return "running";
277
+ }
278
+
279
+ /** Get runtime in milliseconds */
280
+ getRuntime(): number {
281
+ return Date.now() - this.startTime;
282
+ }
283
+
284
+ /** Get completion result (if session has ended) */
285
+ getCompletionResult(): InteractiveShellResult | undefined {
286
+ return this.completionResult;
287
+ }
288
+
289
+ /** Get the session ID */
290
+ getSessionId(): string | null {
291
+ return this.sessionId;
292
+ }
293
+
294
+ /** Kill the session programmatically */
295
+ killSession(): void {
296
+ if (!this.finished) {
297
+ this.finishWithKill();
298
+ }
299
+ }
300
+
228
301
  private startExitCountdown(): void {
229
302
  this.stopCountdown();
230
303
  this.countdownInterval = setInterval(() => {
@@ -276,9 +349,34 @@ export class InteractiveShellOverlay implements Component, Focusable {
276
349
  this.stopQuietTimer();
277
350
  this.quietTimer = setTimeout(() => {
278
351
  this.quietTimer = null;
279
- if (this.state === "hands-free" && this.hasUnsentData) {
280
- this.emitHandsFreeUpdate();
281
- this.hasUnsentData = false;
352
+ if (this.state === "hands-free") {
353
+ // Auto-exit on quiet: kill session when output stops (agent likely finished task)
354
+ if (this.options.autoExitOnQuiet) {
355
+ // Emit final update with any pending output
356
+ if (this.hasUnsentData) {
357
+ this.emitHandsFreeUpdate();
358
+ this.hasUnsentData = false;
359
+ }
360
+ // Send completion notification and auto-close
361
+ if (this.options.onHandsFreeUpdate && this.sessionId) {
362
+ this.options.onHandsFreeUpdate({
363
+ status: "exited",
364
+ sessionId: this.sessionId,
365
+ runtime: Date.now() - this.startTime,
366
+ tail: [],
367
+ tailTruncated: false,
368
+ totalCharsSent: this.totalCharsSent,
369
+ budgetExhausted: this.budgetExhausted,
370
+ });
371
+ }
372
+ this.finishWithKill();
373
+ return;
374
+ }
375
+ // Normal behavior: just emit update
376
+ if (this.hasUnsentData) {
377
+ this.emitHandsFreeUpdate();
378
+ this.hasUnsentData = false;
379
+ }
282
380
  }
283
381
  }, this.currentQuietThreshold);
284
382
  }
@@ -320,6 +418,18 @@ export class InteractiveShellOverlay implements Component, Focusable {
320
418
  const clamped = Math.max(1000, Math.min(30000, thresholdMs));
321
419
  if (clamped === this.currentQuietThreshold) return;
322
420
  this.currentQuietThreshold = clamped;
421
+
422
+ // If a quiet timer is active, restart it with the new threshold
423
+ if (this.quietTimer && this.updateMode === "on-quiet") {
424
+ this.stopQuietTimer();
425
+ this.quietTimer = setTimeout(() => {
426
+ this.quietTimer = null;
427
+ if (this.hasUnsentData && !this.budgetExhausted) {
428
+ this.emitHandsFreeUpdate();
429
+ this.hasUnsentData = false;
430
+ }
431
+ }, this.currentQuietThreshold);
432
+ }
323
433
  }
324
434
 
325
435
  private stopHandsFreeUpdates(): void {
@@ -341,9 +451,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
341
451
  }
342
452
  }
343
453
 
344
- private unregisterActiveSession(): void {
454
+ private unregisterActiveSession(releaseId = false): void {
345
455
  if (this.sessionId && !this.sessionUnregistered) {
346
- sessionManager.unregisterActive(this.sessionId);
456
+ sessionManager.unregisterActive(this.sessionId, releaseId);
347
457
  this.sessionUnregistered = true;
348
458
  }
349
459
  }
@@ -410,22 +520,27 @@ export class InteractiveShellOverlay implements Component, Focusable {
410
520
  }
411
521
 
412
522
  this.stopHandsFreeUpdates();
413
- // Unregister from active sessions since user took over
414
- this.unregisterActiveSession();
415
523
  this.state = "running";
416
524
  this.userTookOver = true;
417
525
 
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
- });
526
+ // Notify agent that user took over (streaming mode)
527
+ // In non-blocking mode, keep session registered so agent can query status
528
+ if (this.options.onHandsFreeUpdate) {
529
+ this.options.onHandsFreeUpdate({
530
+ status: "user-takeover",
531
+ sessionId: this.sessionId,
532
+ runtime: Date.now() - this.startTime,
533
+ tail: [],
534
+ tailTruncated: false,
535
+ userTookOver: true,
536
+ totalCharsSent: this.totalCharsSent,
537
+ budgetExhausted: this.budgetExhausted,
538
+ });
539
+ // Unregister after notification in streaming mode
540
+ this.unregisterActiveSession();
541
+ }
542
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
543
+ // so agent can query and see "user-takeover" status
429
544
 
430
545
  this.tui.requestRender();
431
546
  }
@@ -501,11 +616,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
501
616
  this.stopCountdown();
502
617
  this.stopTimeout();
503
618
  this.stopHandsFreeUpdates();
504
- this.unregisterActiveSession();
619
+
505
620
  const handoffPreview = this.maybeBuildHandoffPreview("exit");
506
621
  const handoff = this.maybeWriteHandoffSnapshot("exit");
507
622
  this.session.dispose();
508
- this.done({
623
+ const result: InteractiveShellResult = {
509
624
  exitCode: this.session.exitCode,
510
625
  signal: this.session.signal,
511
626
  backgrounded: false,
@@ -514,7 +629,17 @@ export class InteractiveShellOverlay implements Component, Focusable {
514
629
  userTookOver: this.userTookOver,
515
630
  handoffPreview,
516
631
  handoff,
517
- });
632
+ };
633
+ this.completionResult = result;
634
+
635
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
636
+ // so agent can query completion result. Agent's query will unregister.
637
+ // In streaming mode, unregister now since agent got final update.
638
+ if (this.options.onHandsFreeUpdate) {
639
+ this.unregisterActiveSession(true);
640
+ }
641
+
642
+ this.done(result);
518
643
  }
519
644
 
520
645
  private finishWithBackground(): void {
@@ -523,11 +648,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
523
648
  this.stopCountdown();
524
649
  this.stopTimeout();
525
650
  this.stopHandsFreeUpdates();
526
- this.unregisterActiveSession();
651
+
527
652
  const handoffPreview = this.maybeBuildHandoffPreview("detach");
528
653
  const handoff = this.maybeWriteHandoffSnapshot("detach");
529
654
  const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason);
530
- this.done({
655
+ const result: InteractiveShellResult = {
531
656
  exitCode: null,
532
657
  backgrounded: true,
533
658
  backgroundId: id,
@@ -536,7 +661,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
536
661
  userTookOver: this.userTookOver,
537
662
  handoffPreview,
538
663
  handoff,
539
- });
664
+ };
665
+ this.completionResult = result;
666
+
667
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
668
+ // so agent can query completion result. Agent's query will unregister.
669
+ if (this.options.onHandsFreeUpdate) {
670
+ this.unregisterActiveSession(true);
671
+ }
672
+
673
+ this.done(result);
540
674
  }
541
675
 
542
676
  private finishWithKill(): void {
@@ -545,12 +679,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
545
679
  this.stopCountdown();
546
680
  this.stopTimeout();
547
681
  this.stopHandsFreeUpdates();
548
- this.unregisterActiveSession();
682
+
549
683
  const handoffPreview = this.maybeBuildHandoffPreview("kill");
550
684
  const handoff = this.maybeWriteHandoffSnapshot("kill");
551
685
  this.session.kill();
552
686
  this.session.dispose();
553
- this.done({
687
+ const result: InteractiveShellResult = {
554
688
  exitCode: null,
555
689
  backgrounded: false,
556
690
  cancelled: true,
@@ -558,7 +692,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
558
692
  userTookOver: this.userTookOver,
559
693
  handoffPreview,
560
694
  handoff,
561
- });
695
+ };
696
+ this.completionResult = result;
697
+
698
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
699
+ // so agent can query completion result. Agent's query will unregister.
700
+ if (this.options.onHandsFreeUpdate) {
701
+ this.unregisterActiveSession(true);
702
+ }
703
+
704
+ this.done(result);
562
705
  }
563
706
 
564
707
  private finishWithTimeout(): void {
@@ -587,13 +730,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
587
730
  }
588
731
 
589
732
  this.stopHandsFreeUpdates();
590
- this.unregisterActiveSession();
591
733
  this.timedOut = true;
592
734
  const handoffPreview = this.maybeBuildHandoffPreview("timeout");
593
735
  const handoff = this.maybeWriteHandoffSnapshot("timeout");
594
736
  this.session.kill();
595
737
  this.session.dispose();
596
- this.done({
738
+ const result: InteractiveShellResult = {
597
739
  exitCode: null,
598
740
  backgrounded: false,
599
741
  cancelled: false,
@@ -602,7 +744,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
602
744
  userTookOver: this.userTookOver,
603
745
  handoffPreview,
604
746
  handoff,
605
- });
747
+ };
748
+ this.completionResult = result;
749
+
750
+ // In non-blocking mode (no onHandsFreeUpdate), keep session registered
751
+ // so agent can query completion result. Agent's query will unregister.
752
+ if (this.options.onHandsFreeUpdate) {
753
+ this.unregisterActiveSession(true);
754
+ }
755
+
756
+ this.done(result);
606
757
  }
607
758
 
608
759
  private handleDoubleEscape(): boolean {
@@ -819,7 +970,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
819
970
  this.stopTimeout();
820
971
  this.stopHandsFreeUpdates();
821
972
  // Safety cleanup in case dispose() is called without going through finishWith*
822
- this.unregisterActiveSession();
973
+ // In non-blocking mode with completion result, keep session so agent can query
974
+ if (!this.completionResult || this.options.onHandsFreeUpdate) {
975
+ this.unregisterActiveSession();
976
+ }
823
977
  }
824
978
  }
825
979
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
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