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 +30 -1
- package/SKILL.md +48 -30
- package/index.ts +205 -32
- package/overlay-component.ts +194 -40
- package/package.json +1 -1
- package/pty-session.ts +77 -28
- package/session-manager.ts +62 -16
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.
|
|
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,
|
|
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
|
-
|
|
81
|
+
// 2. Check status and get new output
|
|
82
|
+
interactive_shell({ sessionId: "calm-reef" })
|
|
83
|
+
// Returns: { status: "running", output: "...", runtime: 30000 }
|
|
80
84
|
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
The user sees the overlay immediately. You get control back to continue working.
|
|
91
105
|
|
|
92
|
-
###
|
|
106
|
+
### Querying Status
|
|
107
|
+
```typescript
|
|
108
|
+
interactive_shell({ sessionId: "calm-reef" })
|
|
109
|
+
```
|
|
93
110
|
|
|
94
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
command: 'pi "Fix all TypeScript errors"',
|
|
102
|
-
mode: "hands-free"
|
|
103
|
-
})
|
|
120
|
+
interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
121
|
+
```
|
|
104
122
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
###
|
|
131
|
+
### Query Output
|
|
114
132
|
|
|
115
|
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
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",
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
- interactive_shell({ sessionId: "calm-reef",
|
|
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
|
|
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
|
|
491
|
-
if (sessionId
|
|
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
|
|
533
|
-
?
|
|
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'
|
|
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
|
|
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
|
|
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;
|
package/overlay-component.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
(
|
|
215
|
-
(
|
|
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"
|
|
280
|
-
|
|
281
|
-
this.
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
619
|
+
|
|
505
620
|
const handoffPreview = this.maybeBuildHandoffPreview("exit");
|
|
506
621
|
const handoff = this.maybeWriteHandoffSnapshot("exit");
|
|
507
622
|
this.session.dispose();
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
this.
|
|
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 }) => {
|
package/session-manager.ts
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|