pi-interactive-shell 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/SKILL.md +41 -25
- package/index.ts +203 -33
- package/overlay-component.ts +202 -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,29 @@
|
|
|
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.0] - 2026-01-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Non-blocking hands-free mode** - Major change: `mode: "hands-free"` now returns immediately with a sessionId. The overlay opens for the user but the agent gets control back right away. Use `interactive_shell({ sessionId })` to query status/output and `interactive_shell({ sessionId, kill: true })` to end the session when done.
|
|
9
|
+
- **Session status queries** - Query active session with just `sessionId` to get current status and any new output since last check.
|
|
10
|
+
- **Kill option** - `interactive_shell({ sessionId, kill: true })` to programmatically end a session.
|
|
11
|
+
- **autoExitOnQuiet** option - Auto-kill session when output stops (after quietThreshold). Use `handsFree: { autoExitOnQuiet: true }` for sessions that should end when the nested agent goes quiet.
|
|
12
|
+
- **Output truncation** - Status queries now truncate output to 10KB (keeping the most recent content) to prevent overwhelming agent context. Truncation is indicated in the response.
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
15
|
+
- **Non-blocking mode session lifecycle** - Sessions now stay registered after completion so agent can query final status. Previously, sessions were unregistered before agent could query completion result.
|
|
16
|
+
- **User takeover in non-blocking mode** - Agent can now see "user-takeover" status when querying. Previously, session was immediately unregistered when user took over.
|
|
17
|
+
- **Type mismatch in registerActive** - Fixed `getOutput` return type to match `OutputResult` interface.
|
|
18
|
+
- **Agent output position after buffer trim** - Fixed `agentOutputPosition` becoming stale when raw buffer is trimmed. When the 1MB buffer limit is exceeded and old content discarded, the agent query position is now clamped to prevent returning empty output or missing data.
|
|
19
|
+
- **killAll() map iteration** - Fixed modifying maps during iteration in `killAll()`. Now collects IDs/entries first to avoid unpredictable behavior when killing sessions triggers unregistration callbacks.
|
|
20
|
+
- **ActiveSessionResult type** - Fixed type mismatch where `output` field was required but never populated. Updated interface to match actual return type from `getResult()`.
|
|
21
|
+
- **Unbounded raw output growth** - rawOutput buffer now capped at 1MB, trimming old content to prevent memory growth in long-running sessions
|
|
22
|
+
- **Session ID reuse** - IDs are only released when session fully terminates, preventing reuse while session still running after takeover
|
|
23
|
+
- **DSR cursor responses** - Fixed stale cursor position when DSR appears mid-chunk; now processes chunks in order, writing to xterm before responding
|
|
24
|
+
- **Active sessions on shutdown** - Hands-free sessions are now killed on `session_shutdown`, preventing orphan processes
|
|
25
|
+
- **Quiet threshold timer** - Changing threshold now restarts any active quiet timer with the new value
|
|
26
|
+
- **Empty string input** - Now shows "(empty)" instead of blank in success message
|
|
27
|
+
- **Hands-free auto-close on exit** - Overlay now closes immediately when process exits in hands-free mode, returning control to the agent instead of waiting for countdown
|
|
8
28
|
- Handoff preview now uses raw output stream instead of xterm buffer. TUI apps using alternate screen buffer (like Codex, Claude, etc.) would show misleading/stale content in the preview.
|
|
9
29
|
|
|
10
30
|
## [0.3.0] - 2026-01-17
|
package/SKILL.md
CHANGED
|
@@ -66,48 +66,64 @@ Agent starts working immediately, user supervises.
|
|
|
66
66
|
interactive_shell({ command: 'pi "Review this codebase for security issues"' })
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
### Hands-Free (Foreground Subagent)
|
|
70
|
-
Agent works autonomously,
|
|
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
|
|
89
93
|
|
|
90
|
-
|
|
94
|
+
### Starting a Session
|
|
95
|
+
```typescript
|
|
96
|
+
const result = interactive_shell({
|
|
97
|
+
command: 'codex "Review this codebase"',
|
|
98
|
+
mode: "hands-free"
|
|
99
|
+
})
|
|
100
|
+
// result.sessionId = "calm-reef"
|
|
101
|
+
// result.status = "running"
|
|
102
|
+
```
|
|
91
103
|
|
|
92
|
-
|
|
104
|
+
The user sees the overlay immediately. You get control back to continue working.
|
|
93
105
|
|
|
94
|
-
|
|
106
|
+
### Querying Status
|
|
107
|
+
```typescript
|
|
108
|
+
interactive_shell({ sessionId: "calm-reef" })
|
|
109
|
+
```
|
|
95
110
|
|
|
96
|
-
|
|
111
|
+
Returns:
|
|
112
|
+
- `status`: "running" | "user-takeover" | "exited" | "killed" | "backgrounded"
|
|
113
|
+
- `output`: New output since last check (incremental)
|
|
114
|
+
- `runtime`: Time elapsed in ms
|
|
97
115
|
|
|
116
|
+
### Ending a Session
|
|
98
117
|
```typescript
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
command: 'pi "Fix all TypeScript errors"',
|
|
102
|
-
mode: "hands-free"
|
|
103
|
-
})
|
|
118
|
+
interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
119
|
+
```
|
|
104
120
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
})
|
|
121
|
+
Kill when you see the task is complete in the output. Returns final status and output.
|
|
122
|
+
|
|
123
|
+
### Sending Input
|
|
124
|
+
```typescript
|
|
125
|
+
interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
|
|
126
|
+
interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
111
127
|
```
|
|
112
128
|
|
|
113
129
|
### Context Budget
|
package/index.ts
CHANGED
|
@@ -294,31 +294,31 @@ The user will see the process in an overlay. They can:
|
|
|
294
294
|
- Detach (double-Escape) to kill or run in background
|
|
295
295
|
- In hands-free mode: type anything to take over control
|
|
296
296
|
|
|
297
|
-
HANDS-FREE MODE:
|
|
298
|
-
When mode="hands-free",
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
Use hands-free when you want to monitor a long-running agent without blocking.
|
|
297
|
+
HANDS-FREE MODE (NON-BLOCKING):
|
|
298
|
+
When mode="hands-free", the tool returns IMMEDIATELY with a sessionId.
|
|
299
|
+
The overlay opens for the user to watch, but you (the agent) get control back right away.
|
|
300
|
+
|
|
301
|
+
Workflow:
|
|
302
|
+
1. Start session: interactive_shell({ command: 'pi "Fix bugs"', mode: "hands-free" })
|
|
303
|
+
-> Returns immediately with sessionId
|
|
304
|
+
2. Check status/output: interactive_shell({ sessionId: "calm-reef" })
|
|
305
|
+
-> Returns current status and any new output since last check
|
|
306
|
+
3. When task is done: interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
307
|
+
-> Kills session and returns final output
|
|
308
|
+
|
|
309
|
+
The user sees the overlay and can:
|
|
310
|
+
- Watch output in real-time
|
|
311
|
+
- Take over by typing (you'll see "user-takeover" status on next query)
|
|
312
|
+
- Kill/background via double-Escape
|
|
314
313
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
- interactive_shell({ sessionId: "calm-reef",
|
|
318
|
-
- interactive_shell({ sessionId: "calm-reef", input:
|
|
314
|
+
QUERYING SESSION STATUS:
|
|
315
|
+
- interactive_shell({ sessionId: "calm-reef" }) - get status + new output
|
|
316
|
+
- interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
|
|
317
|
+
- interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
|
|
319
318
|
|
|
320
|
-
|
|
321
|
-
- interactive_shell({ sessionId: "calm-reef",
|
|
319
|
+
SENDING INPUT:
|
|
320
|
+
- interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
|
|
321
|
+
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
322
322
|
|
|
323
323
|
Named keys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
|
|
324
324
|
Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
|
|
@@ -347,7 +347,12 @@ Examples:
|
|
|
347
347
|
),
|
|
348
348
|
sessionId: Type.Optional(
|
|
349
349
|
Type.String({
|
|
350
|
-
description: "Session ID to
|
|
350
|
+
description: "Session ID to interact with an existing hands-free session",
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
353
|
+
kill: Type.Optional(
|
|
354
|
+
Type.Boolean({
|
|
355
|
+
description: "Kill the session (requires sessionId). Use when task appears complete.",
|
|
351
356
|
}),
|
|
352
357
|
),
|
|
353
358
|
settings: Type.Optional(
|
|
@@ -427,6 +432,11 @@ Examples:
|
|
|
427
432
|
maxTotalChars: Type.Optional(
|
|
428
433
|
Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
|
|
429
434
|
),
|
|
435
|
+
autoExitOnQuiet: Type.Optional(
|
|
436
|
+
Type.Boolean({
|
|
437
|
+
description: "Auto-kill session when output stops (after quietThreshold). Use for agents that don't exit on their own after completing a task.",
|
|
438
|
+
}),
|
|
439
|
+
),
|
|
430
440
|
}),
|
|
431
441
|
),
|
|
432
442
|
handoffPreview: Type.Optional(
|
|
@@ -456,6 +466,7 @@ Examples:
|
|
|
456
466
|
const {
|
|
457
467
|
command,
|
|
458
468
|
sessionId,
|
|
469
|
+
kill,
|
|
459
470
|
settings,
|
|
460
471
|
input,
|
|
461
472
|
cwd,
|
|
@@ -469,6 +480,7 @@ Examples:
|
|
|
469
480
|
} = params as {
|
|
470
481
|
command?: string;
|
|
471
482
|
sessionId?: string;
|
|
483
|
+
kill?: boolean;
|
|
472
484
|
settings?: { updateInterval?: number; quietThreshold?: number };
|
|
473
485
|
input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
|
|
474
486
|
cwd?: string;
|
|
@@ -481,14 +493,15 @@ Examples:
|
|
|
481
493
|
quietThreshold?: number;
|
|
482
494
|
updateMaxChars?: number;
|
|
483
495
|
maxTotalChars?: number;
|
|
496
|
+
autoExitOnQuiet?: boolean;
|
|
484
497
|
};
|
|
485
498
|
handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
|
|
486
499
|
handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
|
|
487
500
|
timeout?: number;
|
|
488
501
|
};
|
|
489
502
|
|
|
490
|
-
// Mode 1: Interact with existing session (send input
|
|
491
|
-
if (sessionId
|
|
503
|
+
// Mode 1: Interact with existing session (query status, send input, kill, or change settings)
|
|
504
|
+
if (sessionId) {
|
|
492
505
|
const session = sessionManager.getActive(sessionId);
|
|
493
506
|
if (!session) {
|
|
494
507
|
return {
|
|
@@ -498,6 +511,34 @@ Examples:
|
|
|
498
511
|
};
|
|
499
512
|
}
|
|
500
513
|
|
|
514
|
+
// Kill session if requested
|
|
515
|
+
if (kill) {
|
|
516
|
+
const { output, truncated, totalBytes } = session.getOutput();
|
|
517
|
+
const status = session.getStatus();
|
|
518
|
+
const runtime = session.getRuntime();
|
|
519
|
+
session.kill();
|
|
520
|
+
sessionManager.unregisterActive(sessionId, true);
|
|
521
|
+
|
|
522
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
523
|
+
return {
|
|
524
|
+
content: [
|
|
525
|
+
{
|
|
526
|
+
type: "text",
|
|
527
|
+
text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}:\n${output}` : ""}`,
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
details: {
|
|
531
|
+
sessionId,
|
|
532
|
+
status: "killed",
|
|
533
|
+
runtime,
|
|
534
|
+
output,
|
|
535
|
+
outputTruncated: truncated,
|
|
536
|
+
outputTotalBytes: totalBytes,
|
|
537
|
+
previousStatus: status,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
501
542
|
const actions: string[] = [];
|
|
502
543
|
|
|
503
544
|
// Apply settings changes
|
|
@@ -529,9 +570,11 @@ Examples:
|
|
|
529
570
|
|
|
530
571
|
const inputDesc =
|
|
531
572
|
typeof input === "string"
|
|
532
|
-
? input.length
|
|
533
|
-
?
|
|
534
|
-
: input
|
|
573
|
+
? input.length === 0
|
|
574
|
+
? "(empty)"
|
|
575
|
+
: input.length > 50
|
|
576
|
+
? `${input.slice(0, 50)}...`
|
|
577
|
+
: input
|
|
535
578
|
: [
|
|
536
579
|
input.text ?? "",
|
|
537
580
|
input.keys ? `keys:[${input.keys.join(",")}]` : "",
|
|
@@ -544,6 +587,59 @@ Examples:
|
|
|
544
587
|
actions.push(`sent: ${inputDesc}`);
|
|
545
588
|
}
|
|
546
589
|
|
|
590
|
+
// If only querying status (no input, no settings, no kill)
|
|
591
|
+
if (actions.length === 0) {
|
|
592
|
+
const { output, truncated, totalBytes } = session.getOutput();
|
|
593
|
+
const status = session.getStatus();
|
|
594
|
+
const runtime = session.getRuntime();
|
|
595
|
+
const result = session.getResult();
|
|
596
|
+
|
|
597
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated to last 10KB)` : "";
|
|
598
|
+
const hasOutput = output.length > 0;
|
|
599
|
+
|
|
600
|
+
// Check if session completed
|
|
601
|
+
if (result) {
|
|
602
|
+
sessionManager.unregisterActive(sessionId, true);
|
|
603
|
+
return {
|
|
604
|
+
content: [
|
|
605
|
+
{
|
|
606
|
+
type: "text",
|
|
607
|
+
text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
details: {
|
|
611
|
+
sessionId,
|
|
612
|
+
status,
|
|
613
|
+
runtime,
|
|
614
|
+
output,
|
|
615
|
+
outputTruncated: truncated,
|
|
616
|
+
outputTotalBytes: totalBytes,
|
|
617
|
+
exitCode: result.exitCode,
|
|
618
|
+
signal: result.signal,
|
|
619
|
+
backgroundId: result.backgroundId,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
content: [
|
|
626
|
+
{
|
|
627
|
+
type: "text",
|
|
628
|
+
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nNew output${truncatedNote}:\n${output}` : "\n\n(no new output)"}`,
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
details: {
|
|
632
|
+
sessionId,
|
|
633
|
+
status,
|
|
634
|
+
runtime,
|
|
635
|
+
output,
|
|
636
|
+
outputTruncated: truncated,
|
|
637
|
+
outputTotalBytes: totalBytes,
|
|
638
|
+
hasNewOutput: hasOutput,
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
547
643
|
return {
|
|
548
644
|
content: [{ type: "text", text: `Session ${sessionId}: ${actions.join(", ")}` }],
|
|
549
645
|
details: { sessionId, actions },
|
|
@@ -556,7 +652,7 @@ Examples:
|
|
|
556
652
|
content: [
|
|
557
653
|
{
|
|
558
654
|
type: "text",
|
|
559
|
-
text: "Either 'command' (to start a session) or 'sessionId'
|
|
655
|
+
text: "Either 'command' (to start a session) or 'sessionId' (to query/interact with existing session) is required",
|
|
560
656
|
},
|
|
561
657
|
],
|
|
562
658
|
isError: true,
|
|
@@ -576,16 +672,89 @@ Examples:
|
|
|
576
672
|
const config = loadConfig(effectiveCwd);
|
|
577
673
|
const isHandsFree = mode === "hands-free";
|
|
578
674
|
|
|
579
|
-
// Generate sessionId early so it's available
|
|
675
|
+
// Generate sessionId early so it's available immediately
|
|
580
676
|
const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
|
|
581
677
|
|
|
678
|
+
// For hands-free mode: non-blocking - return immediately with sessionId
|
|
679
|
+
// Agent can then query status/output via sessionId and kill when done
|
|
680
|
+
if (isHandsFree && generatedSessionId) {
|
|
681
|
+
// Start overlay but don't await - it runs in background
|
|
682
|
+
const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
|
|
683
|
+
(tui, theme, _kb, done) =>
|
|
684
|
+
new InteractiveShellOverlay(
|
|
685
|
+
tui,
|
|
686
|
+
theme,
|
|
687
|
+
{
|
|
688
|
+
command,
|
|
689
|
+
cwd: effectiveCwd,
|
|
690
|
+
name,
|
|
691
|
+
reason,
|
|
692
|
+
mode,
|
|
693
|
+
sessionId: generatedSessionId,
|
|
694
|
+
handsFreeUpdateMode: handsFree?.updateMode,
|
|
695
|
+
handsFreeUpdateInterval: handsFree?.updateInterval,
|
|
696
|
+
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
697
|
+
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
698
|
+
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
699
|
+
autoExitOnQuiet: handsFree?.autoExitOnQuiet,
|
|
700
|
+
// No onHandsFreeUpdate in non-blocking mode - agent queries directly
|
|
701
|
+
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
702
|
+
handoffPreviewLines: handoffPreview?.lines,
|
|
703
|
+
handoffPreviewMaxChars: handoffPreview?.maxChars,
|
|
704
|
+
handoffSnapshotEnabled: handoffSnapshot?.enabled,
|
|
705
|
+
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
706
|
+
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
707
|
+
timeout,
|
|
708
|
+
},
|
|
709
|
+
config,
|
|
710
|
+
done,
|
|
711
|
+
),
|
|
712
|
+
{
|
|
713
|
+
overlay: true,
|
|
714
|
+
overlayOptions: {
|
|
715
|
+
width: `${config.overlayWidthPercent}%`,
|
|
716
|
+
maxHeight: `${config.overlayHeightPercent}%`,
|
|
717
|
+
anchor: "center",
|
|
718
|
+
margin: 1,
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
// Handle overlay completion in background (cleanup when user closes)
|
|
724
|
+
overlayPromise.then((result) => {
|
|
725
|
+
// Session already handles cleanup via finishWith* methods
|
|
726
|
+
// This just ensures the promise doesn't cause unhandled rejection
|
|
727
|
+
if (result.userTookOver) {
|
|
728
|
+
// User took over - session continues interactively
|
|
729
|
+
}
|
|
730
|
+
}).catch(() => {
|
|
731
|
+
// Ignore errors - session cleanup handles this
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Return immediately - agent can query via sessionId
|
|
735
|
+
return {
|
|
736
|
+
content: [
|
|
737
|
+
{
|
|
738
|
+
type: "text",
|
|
739
|
+
text: `Session started: ${generatedSessionId}\nCommand: ${command}\n\nUse interactive_shell({ sessionId: "${generatedSessionId}" }) to check status/output.\nUse interactive_shell({ sessionId: "${generatedSessionId}", kill: true }) to end when done.`,
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
details: {
|
|
743
|
+
sessionId: generatedSessionId,
|
|
744
|
+
status: "running",
|
|
745
|
+
command,
|
|
746
|
+
reason,
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Interactive mode: blocking - wait for overlay to close
|
|
582
752
|
onUpdate?.({
|
|
583
|
-
content: [{ type: "text", text: `Opening
|
|
753
|
+
content: [{ type: "text", text: `Opening: ${command}` }],
|
|
584
754
|
details: {
|
|
585
755
|
exitCode: null,
|
|
586
756
|
backgrounded: false,
|
|
587
757
|
cancelled: false,
|
|
588
|
-
sessionId: generatedSessionId,
|
|
589
758
|
},
|
|
590
759
|
});
|
|
591
760
|
|
|
@@ -606,6 +775,7 @@ Examples:
|
|
|
606
775
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
607
776
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
608
777
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
778
|
+
autoExitOnQuiet: handsFree?.autoExitOnQuiet,
|
|
609
779
|
onHandsFreeUpdate: isHandsFree
|
|
610
780
|
? (update) => {
|
|
611
781
|
let statusText: string;
|
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,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
120
122
|
private lastDataTime = 0;
|
|
121
123
|
private quietTimer: ReturnType<typeof setTimeout> | null = null;
|
|
122
124
|
private hasUnsentData = false;
|
|
125
|
+
// Non-blocking mode: track status and output for agent queries
|
|
126
|
+
private agentOutputPosition = 0; // Track last read position for incremental output
|
|
127
|
+
private completionResult: InteractiveShellResult | undefined;
|
|
123
128
|
|
|
124
129
|
constructor(
|
|
125
130
|
tui: TUI,
|
|
@@ -169,7 +174,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
169
174
|
// Stop timeout to prevent double done() call
|
|
170
175
|
this.stopTimeout();
|
|
171
176
|
|
|
172
|
-
//
|
|
177
|
+
// In hands-free mode (user hasn't taken over): send exited notification and auto-close immediately
|
|
173
178
|
if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
|
|
174
179
|
// Flush any pending output before sending exited notification
|
|
175
180
|
if (this.hasUnsentData || this.updateMode === "interval") {
|
|
@@ -186,8 +191,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
186
191
|
totalCharsSent: this.totalCharsSent,
|
|
187
192
|
budgetExhausted: this.budgetExhausted,
|
|
188
193
|
});
|
|
189
|
-
|
|
194
|
+
// Auto-close immediately in hands-free mode - agent should get control back
|
|
195
|
+
this.finishWithExit();
|
|
196
|
+
return;
|
|
190
197
|
}
|
|
198
|
+
|
|
199
|
+
// Interactive mode (or user took over): show exit state with countdown
|
|
191
200
|
this.stopHandsFreeUpdates();
|
|
192
201
|
this.state = "exited";
|
|
193
202
|
this.exitCountdown = this.config.exitAutoCloseDelay;
|
|
@@ -207,13 +216,19 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
207
216
|
this.state = "hands-free";
|
|
208
217
|
// Use provided sessionId or generate one
|
|
209
218
|
this.sessionId = options.sessionId ?? generateSessionId(options.name);
|
|
210
|
-
sessionManager.registerActive(
|
|
211
|
-
this.sessionId,
|
|
212
|
-
options.command,
|
|
213
|
-
|
|
214
|
-
(
|
|
215
|
-
(
|
|
216
|
-
|
|
219
|
+
sessionManager.registerActive({
|
|
220
|
+
id: this.sessionId,
|
|
221
|
+
command: options.command,
|
|
222
|
+
reason: options.reason,
|
|
223
|
+
write: (data) => this.session.write(data),
|
|
224
|
+
kill: () => this.killSession(),
|
|
225
|
+
getOutput: () => this.getOutputSinceLastCheck(),
|
|
226
|
+
getStatus: () => this.getSessionStatus(),
|
|
227
|
+
getRuntime: () => this.getRuntime(),
|
|
228
|
+
getResult: () => this.getCompletionResult(),
|
|
229
|
+
setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
|
|
230
|
+
setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
|
|
231
|
+
});
|
|
217
232
|
this.startHandsFreeUpdates();
|
|
218
233
|
}
|
|
219
234
|
|
|
@@ -225,6 +240,72 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
225
240
|
}
|
|
226
241
|
}
|
|
227
242
|
|
|
243
|
+
// Public methods for non-blocking mode (agent queries)
|
|
244
|
+
|
|
245
|
+
// Max output per status query (10KB) - prevents overwhelming agent context
|
|
246
|
+
private static readonly MAX_STATUS_OUTPUT = 10 * 1024;
|
|
247
|
+
|
|
248
|
+
/** Get output since last check (incremental, truncated if too large) */
|
|
249
|
+
getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: number } {
|
|
250
|
+
const fullOutput = this.session.getRawStream({ stripAnsi: true });
|
|
251
|
+
|
|
252
|
+
// Handle case where buffer was trimmed and our position is now past the end
|
|
253
|
+
// This happens when rawOutput exceeds 1MB and older content is discarded
|
|
254
|
+
const clampedPosition = Math.min(this.agentOutputPosition, fullOutput.length);
|
|
255
|
+
const newOutput = fullOutput.substring(clampedPosition);
|
|
256
|
+
const totalBytes = newOutput.length;
|
|
257
|
+
|
|
258
|
+
// Always advance position (even if truncated, we don't want to re-send old data)
|
|
259
|
+
this.agentOutputPosition = fullOutput.length;
|
|
260
|
+
|
|
261
|
+
// Truncate if too large (keep the END, which is most recent/relevant)
|
|
262
|
+
if (newOutput.length > InteractiveShellOverlay.MAX_STATUS_OUTPUT) {
|
|
263
|
+
const truncated = newOutput.slice(-InteractiveShellOverlay.MAX_STATUS_OUTPUT);
|
|
264
|
+
return {
|
|
265
|
+
output: `[...${totalBytes - InteractiveShellOverlay.MAX_STATUS_OUTPUT} bytes truncated...]\n${truncated}`,
|
|
266
|
+
truncated: true,
|
|
267
|
+
totalBytes,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { output: newOutput, truncated: false, totalBytes };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Get current session status */
|
|
275
|
+
getSessionStatus(): "running" | "user-takeover" | "exited" | "killed" | "backgrounded" {
|
|
276
|
+
if (this.completionResult) {
|
|
277
|
+
if (this.completionResult.cancelled) return "killed";
|
|
278
|
+
if (this.completionResult.backgrounded) return "backgrounded";
|
|
279
|
+
if (this.userTookOver) return "user-takeover";
|
|
280
|
+
return "exited";
|
|
281
|
+
}
|
|
282
|
+
if (this.userTookOver) return "user-takeover";
|
|
283
|
+
if (this.state === "exited") return "exited";
|
|
284
|
+
return "running";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Get runtime in milliseconds */
|
|
288
|
+
getRuntime(): number {
|
|
289
|
+
return Date.now() - this.startTime;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Get completion result (if session has ended) */
|
|
293
|
+
getCompletionResult(): InteractiveShellResult | undefined {
|
|
294
|
+
return this.completionResult;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Get the session ID */
|
|
298
|
+
getSessionId(): string | null {
|
|
299
|
+
return this.sessionId;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Kill the session programmatically */
|
|
303
|
+
killSession(): void {
|
|
304
|
+
if (!this.finished) {
|
|
305
|
+
this.finishWithKill();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
228
309
|
private startExitCountdown(): void {
|
|
229
310
|
this.stopCountdown();
|
|
230
311
|
this.countdownInterval = setInterval(() => {
|
|
@@ -276,9 +357,34 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
276
357
|
this.stopQuietTimer();
|
|
277
358
|
this.quietTimer = setTimeout(() => {
|
|
278
359
|
this.quietTimer = null;
|
|
279
|
-
if (this.state === "hands-free"
|
|
280
|
-
|
|
281
|
-
this.
|
|
360
|
+
if (this.state === "hands-free") {
|
|
361
|
+
// Auto-exit on quiet: kill session when output stops (agent likely finished task)
|
|
362
|
+
if (this.options.autoExitOnQuiet) {
|
|
363
|
+
// Emit final update with any pending output
|
|
364
|
+
if (this.hasUnsentData) {
|
|
365
|
+
this.emitHandsFreeUpdate();
|
|
366
|
+
this.hasUnsentData = false;
|
|
367
|
+
}
|
|
368
|
+
// Send completion notification and auto-close
|
|
369
|
+
if (this.options.onHandsFreeUpdate && this.sessionId) {
|
|
370
|
+
this.options.onHandsFreeUpdate({
|
|
371
|
+
status: "exited",
|
|
372
|
+
sessionId: this.sessionId,
|
|
373
|
+
runtime: Date.now() - this.startTime,
|
|
374
|
+
tail: [],
|
|
375
|
+
tailTruncated: false,
|
|
376
|
+
totalCharsSent: this.totalCharsSent,
|
|
377
|
+
budgetExhausted: this.budgetExhausted,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
this.finishWithKill();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Normal behavior: just emit update
|
|
384
|
+
if (this.hasUnsentData) {
|
|
385
|
+
this.emitHandsFreeUpdate();
|
|
386
|
+
this.hasUnsentData = false;
|
|
387
|
+
}
|
|
282
388
|
}
|
|
283
389
|
}, this.currentQuietThreshold);
|
|
284
390
|
}
|
|
@@ -320,6 +426,18 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
320
426
|
const clamped = Math.max(1000, Math.min(30000, thresholdMs));
|
|
321
427
|
if (clamped === this.currentQuietThreshold) return;
|
|
322
428
|
this.currentQuietThreshold = clamped;
|
|
429
|
+
|
|
430
|
+
// If a quiet timer is active, restart it with the new threshold
|
|
431
|
+
if (this.quietTimer && this.updateMode === "on-quiet") {
|
|
432
|
+
this.stopQuietTimer();
|
|
433
|
+
this.quietTimer = setTimeout(() => {
|
|
434
|
+
this.quietTimer = null;
|
|
435
|
+
if (this.hasUnsentData && !this.budgetExhausted) {
|
|
436
|
+
this.emitHandsFreeUpdate();
|
|
437
|
+
this.hasUnsentData = false;
|
|
438
|
+
}
|
|
439
|
+
}, this.currentQuietThreshold);
|
|
440
|
+
}
|
|
323
441
|
}
|
|
324
442
|
|
|
325
443
|
private stopHandsFreeUpdates(): void {
|
|
@@ -341,9 +459,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
341
459
|
}
|
|
342
460
|
}
|
|
343
461
|
|
|
344
|
-
private unregisterActiveSession(): void {
|
|
462
|
+
private unregisterActiveSession(releaseId = false): void {
|
|
345
463
|
if (this.sessionId && !this.sessionUnregistered) {
|
|
346
|
-
sessionManager.unregisterActive(this.sessionId);
|
|
464
|
+
sessionManager.unregisterActive(this.sessionId, releaseId);
|
|
347
465
|
this.sessionUnregistered = true;
|
|
348
466
|
}
|
|
349
467
|
}
|
|
@@ -410,22 +528,27 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
410
528
|
}
|
|
411
529
|
|
|
412
530
|
this.stopHandsFreeUpdates();
|
|
413
|
-
// Unregister from active sessions since user took over
|
|
414
|
-
this.unregisterActiveSession();
|
|
415
531
|
this.state = "running";
|
|
416
532
|
this.userTookOver = true;
|
|
417
533
|
|
|
418
|
-
// Notify agent that user took over
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
534
|
+
// Notify agent that user took over (streaming mode)
|
|
535
|
+
// In non-blocking mode, keep session registered so agent can query status
|
|
536
|
+
if (this.options.onHandsFreeUpdate) {
|
|
537
|
+
this.options.onHandsFreeUpdate({
|
|
538
|
+
status: "user-takeover",
|
|
539
|
+
sessionId: this.sessionId,
|
|
540
|
+
runtime: Date.now() - this.startTime,
|
|
541
|
+
tail: [],
|
|
542
|
+
tailTruncated: false,
|
|
543
|
+
userTookOver: true,
|
|
544
|
+
totalCharsSent: this.totalCharsSent,
|
|
545
|
+
budgetExhausted: this.budgetExhausted,
|
|
546
|
+
});
|
|
547
|
+
// Unregister after notification in streaming mode
|
|
548
|
+
this.unregisterActiveSession();
|
|
549
|
+
}
|
|
550
|
+
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
551
|
+
// so agent can query and see "user-takeover" status
|
|
429
552
|
|
|
430
553
|
this.tui.requestRender();
|
|
431
554
|
}
|
|
@@ -501,11 +624,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
501
624
|
this.stopCountdown();
|
|
502
625
|
this.stopTimeout();
|
|
503
626
|
this.stopHandsFreeUpdates();
|
|
504
|
-
|
|
627
|
+
|
|
505
628
|
const handoffPreview = this.maybeBuildHandoffPreview("exit");
|
|
506
629
|
const handoff = this.maybeWriteHandoffSnapshot("exit");
|
|
507
630
|
this.session.dispose();
|
|
508
|
-
|
|
631
|
+
const result: InteractiveShellResult = {
|
|
509
632
|
exitCode: this.session.exitCode,
|
|
510
633
|
signal: this.session.signal,
|
|
511
634
|
backgrounded: false,
|
|
@@ -514,7 +637,17 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
514
637
|
userTookOver: this.userTookOver,
|
|
515
638
|
handoffPreview,
|
|
516
639
|
handoff,
|
|
517
|
-
}
|
|
640
|
+
};
|
|
641
|
+
this.completionResult = result;
|
|
642
|
+
|
|
643
|
+
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
644
|
+
// so agent can query completion result. Agent's query will unregister.
|
|
645
|
+
// In streaming mode, unregister now since agent got final update.
|
|
646
|
+
if (this.options.onHandsFreeUpdate) {
|
|
647
|
+
this.unregisterActiveSession(true);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
this.done(result);
|
|
518
651
|
}
|
|
519
652
|
|
|
520
653
|
private finishWithBackground(): void {
|
|
@@ -523,11 +656,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
523
656
|
this.stopCountdown();
|
|
524
657
|
this.stopTimeout();
|
|
525
658
|
this.stopHandsFreeUpdates();
|
|
526
|
-
|
|
659
|
+
|
|
527
660
|
const handoffPreview = this.maybeBuildHandoffPreview("detach");
|
|
528
661
|
const handoff = this.maybeWriteHandoffSnapshot("detach");
|
|
529
662
|
const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason);
|
|
530
|
-
|
|
663
|
+
const result: InteractiveShellResult = {
|
|
531
664
|
exitCode: null,
|
|
532
665
|
backgrounded: true,
|
|
533
666
|
backgroundId: id,
|
|
@@ -536,7 +669,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
536
669
|
userTookOver: this.userTookOver,
|
|
537
670
|
handoffPreview,
|
|
538
671
|
handoff,
|
|
539
|
-
}
|
|
672
|
+
};
|
|
673
|
+
this.completionResult = result;
|
|
674
|
+
|
|
675
|
+
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
676
|
+
// so agent can query completion result. Agent's query will unregister.
|
|
677
|
+
if (this.options.onHandsFreeUpdate) {
|
|
678
|
+
this.unregisterActiveSession(true);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
this.done(result);
|
|
540
682
|
}
|
|
541
683
|
|
|
542
684
|
private finishWithKill(): void {
|
|
@@ -545,12 +687,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
545
687
|
this.stopCountdown();
|
|
546
688
|
this.stopTimeout();
|
|
547
689
|
this.stopHandsFreeUpdates();
|
|
548
|
-
|
|
690
|
+
|
|
549
691
|
const handoffPreview = this.maybeBuildHandoffPreview("kill");
|
|
550
692
|
const handoff = this.maybeWriteHandoffSnapshot("kill");
|
|
551
693
|
this.session.kill();
|
|
552
694
|
this.session.dispose();
|
|
553
|
-
|
|
695
|
+
const result: InteractiveShellResult = {
|
|
554
696
|
exitCode: null,
|
|
555
697
|
backgrounded: false,
|
|
556
698
|
cancelled: true,
|
|
@@ -558,7 +700,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
558
700
|
userTookOver: this.userTookOver,
|
|
559
701
|
handoffPreview,
|
|
560
702
|
handoff,
|
|
561
|
-
}
|
|
703
|
+
};
|
|
704
|
+
this.completionResult = result;
|
|
705
|
+
|
|
706
|
+
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
707
|
+
// so agent can query completion result. Agent's query will unregister.
|
|
708
|
+
if (this.options.onHandsFreeUpdate) {
|
|
709
|
+
this.unregisterActiveSession(true);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
this.done(result);
|
|
562
713
|
}
|
|
563
714
|
|
|
564
715
|
private finishWithTimeout(): void {
|
|
@@ -587,13 +738,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
587
738
|
}
|
|
588
739
|
|
|
589
740
|
this.stopHandsFreeUpdates();
|
|
590
|
-
this.unregisterActiveSession();
|
|
591
741
|
this.timedOut = true;
|
|
592
742
|
const handoffPreview = this.maybeBuildHandoffPreview("timeout");
|
|
593
743
|
const handoff = this.maybeWriteHandoffSnapshot("timeout");
|
|
594
744
|
this.session.kill();
|
|
595
745
|
this.session.dispose();
|
|
596
|
-
|
|
746
|
+
const result: InteractiveShellResult = {
|
|
597
747
|
exitCode: null,
|
|
598
748
|
backgrounded: false,
|
|
599
749
|
cancelled: false,
|
|
@@ -602,7 +752,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
602
752
|
userTookOver: this.userTookOver,
|
|
603
753
|
handoffPreview,
|
|
604
754
|
handoff,
|
|
605
|
-
}
|
|
755
|
+
};
|
|
756
|
+
this.completionResult = result;
|
|
757
|
+
|
|
758
|
+
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
759
|
+
// so agent can query completion result. Agent's query will unregister.
|
|
760
|
+
if (this.options.onHandsFreeUpdate) {
|
|
761
|
+
this.unregisterActiveSession(true);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
this.done(result);
|
|
606
765
|
}
|
|
607
766
|
|
|
608
767
|
private handleDoubleEscape(): boolean {
|
|
@@ -819,7 +978,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
819
978
|
this.stopTimeout();
|
|
820
979
|
this.stopHandsFreeUpdates();
|
|
821
980
|
// Safety cleanup in case dispose() is called without going through finishWith*
|
|
822
|
-
|
|
981
|
+
// In non-blocking mode with completion result, keep session so agent can query
|
|
982
|
+
if (!this.completionResult || this.options.onHandsFreeUpdate) {
|
|
983
|
+
this.unregisterActiveSession();
|
|
984
|
+
}
|
|
823
985
|
}
|
|
824
986
|
}
|
|
825
987
|
|
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
|
|