pi-interactive-shell 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/SKILL.md +10 -6
- package/config.ts +15 -3
- package/index.ts +147 -11
- package/overlay-component.ts +82 -31
- package/package.json +1 -1
- package/session-manager.ts +13 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `pi-interactive-shell` extension will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.2] - 2026-01-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Query rate limiting** - Queries are limited to once every 60 seconds by default. If you query too soon, the tool automatically waits until the limit expires before returning (blocking behavior). Configurable via `minQueryIntervalSeconds` in settings (range: 5-300 seconds). Note: Rate limiting does not apply to completed sessions or kills - you can always query the final result immediately.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **autoExitOnQuiet now defaults to true** - In hands-free mode, sessions auto-kill when output stops (~5s of quiet). Set `handsFree: { autoExitOnQuiet: false }` to disable.
|
|
12
|
+
- **Smaller default overlay** - Height reduced from 90% to 45%. Configurable via `overlayHeightPercent` in settings (range: 20-90%).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **Rate limit wait now interruptible** - When waiting for rate limit, the wait is interrupted immediately if the session completes (user kills, process exits, etc.). Uses Promise.race with onComplete callback instead of blocking sleep.
|
|
16
|
+
- **scrollbackLines NaN handling** - Config now uses `clampInt` like other numeric fields, preventing NaN from breaking xterm scrollback.
|
|
17
|
+
- **autoExitOnQuiet status mismatch** - Now sends "killed" status (not "exited") to match `finishWithKill()` behavior.
|
|
18
|
+
- **hasNewOutput semantics** - Renamed to `hasOutput` since we use tail-based output, not incremental tracking.
|
|
19
|
+
- **dispose() orphaned sessions** - Now kills running processes before unregistering to prevent orphaned sessions.
|
|
20
|
+
- **killAll() premature ID release** - IDs now released via natural cleanup after process exit, not immediately after kill() call.
|
|
21
|
+
|
|
22
|
+
## [0.4.1] - 2026-01-17
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
5
31
|
## [0.4.0] - 2026-01-17
|
|
6
32
|
|
|
7
33
|
### Added
|
package/README.md
CHANGED
|
@@ -146,7 +146,7 @@ Project: `<cwd>/.pi/interactive-shell.json`
|
|
|
146
146
|
"doubleEscapeThreshold": 300,
|
|
147
147
|
"exitAutoCloseDelay": 10,
|
|
148
148
|
"overlayWidthPercent": 95,
|
|
149
|
-
"overlayHeightPercent":
|
|
149
|
+
"overlayHeightPercent": 45,
|
|
150
150
|
"scrollbackLines": 5000,
|
|
151
151
|
"ansiReemit": true,
|
|
152
152
|
"handoffPreviewEnabled": true,
|
package/SKILL.md
CHANGED
|
@@ -110,9 +110,11 @@ interactive_shell({ sessionId: "calm-reef" })
|
|
|
110
110
|
|
|
111
111
|
Returns:
|
|
112
112
|
- `status`: "running" | "user-takeover" | "exited" | "killed" | "backgrounded"
|
|
113
|
-
- `output`:
|
|
113
|
+
- `output`: Last 20 lines of rendered terminal (clean, no TUI animation noise)
|
|
114
114
|
- `runtime`: Time elapsed in ms
|
|
115
115
|
|
|
116
|
+
**Rate limited:** Queries are limited to once every 60 seconds. If you query too soon, the tool will automatically wait until the limit expires before returning. The user is watching the overlay in real-time - you're just checking in periodically.
|
|
117
|
+
|
|
116
118
|
### Ending a Session
|
|
117
119
|
```typescript
|
|
118
120
|
interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
@@ -120,18 +122,20 @@ interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
|
120
122
|
|
|
121
123
|
Kill when you see the task is complete in the output. Returns final status and output.
|
|
122
124
|
|
|
125
|
+
**Auto-exit:** In hands-free mode, sessions auto-kill when output stops (after ~5 seconds of quiet). This means when an agent finishes its task, the session closes automatically. You can disable this with `handsFree: { autoExitOnQuiet: false }`.
|
|
126
|
+
|
|
123
127
|
### Sending Input
|
|
124
128
|
```typescript
|
|
125
129
|
interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
|
|
126
130
|
interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
127
131
|
```
|
|
128
132
|
|
|
129
|
-
###
|
|
133
|
+
### Query Output
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
134
|
-
-
|
|
135
|
+
Status queries return **rendered terminal output** (what's actually on screen), not raw stream:
|
|
136
|
+
- Last 20 lines of the terminal, clean and readable
|
|
137
|
+
- No TUI animation noise (spinners, progress bars, etc.)
|
|
138
|
+
- Max 5KB per query to keep context manageable
|
|
135
139
|
- Configure via `handsFree.maxTotalChars`
|
|
136
140
|
|
|
137
141
|
```typescript
|
package/config.ts
CHANGED
|
@@ -21,13 +21,15 @@ export interface InteractiveShellConfig {
|
|
|
21
21
|
handsFreeQuietThreshold: number;
|
|
22
22
|
handsFreeUpdateMaxChars: number;
|
|
23
23
|
handsFreeMaxTotalChars: number;
|
|
24
|
+
// Query rate limiting
|
|
25
|
+
minQueryIntervalSeconds: number;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const DEFAULT_CONFIG: InteractiveShellConfig = {
|
|
27
29
|
doubleEscapeThreshold: 300,
|
|
28
30
|
exitAutoCloseDelay: 10,
|
|
29
31
|
overlayWidthPercent: 95,
|
|
30
|
-
overlayHeightPercent:
|
|
32
|
+
overlayHeightPercent: 45,
|
|
31
33
|
scrollbackLines: 5000,
|
|
32
34
|
ansiReemit: true,
|
|
33
35
|
handoffPreviewEnabled: true,
|
|
@@ -42,6 +44,8 @@ const DEFAULT_CONFIG: InteractiveShellConfig = {
|
|
|
42
44
|
handsFreeQuietThreshold: 5000,
|
|
43
45
|
handsFreeUpdateMaxChars: 1500,
|
|
44
46
|
handsFreeMaxTotalChars: 100000,
|
|
47
|
+
// Query rate limiting (default 60 seconds between queries)
|
|
48
|
+
minQueryIntervalSeconds: 60,
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
export function loadConfig(cwd: string): InteractiveShellConfig {
|
|
@@ -72,8 +76,9 @@ export function loadConfig(cwd: string): InteractiveShellConfig {
|
|
|
72
76
|
return {
|
|
73
77
|
...merged,
|
|
74
78
|
overlayWidthPercent: clampPercent(merged.overlayWidthPercent, DEFAULT_CONFIG.overlayWidthPercent),
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
// Height: 20-90% range (default 45%)
|
|
80
|
+
overlayHeightPercent: clampInt(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent, 20, 90),
|
|
81
|
+
scrollbackLines: clampInt(merged.scrollbackLines, DEFAULT_CONFIG.scrollbackLines, 200, 50000),
|
|
77
82
|
ansiReemit: merged.ansiReemit !== false,
|
|
78
83
|
handoffPreviewEnabled: merged.handoffPreviewEnabled !== false,
|
|
79
84
|
handoffPreviewLines: clampInt(merged.handoffPreviewLines, DEFAULT_CONFIG.handoffPreviewLines, 0, 500),
|
|
@@ -117,6 +122,13 @@ export function loadConfig(cwd: string): InteractiveShellConfig {
|
|
|
117
122
|
10000,
|
|
118
123
|
1000000,
|
|
119
124
|
),
|
|
125
|
+
// Query rate limiting (min 5 seconds, max 300 seconds)
|
|
126
|
+
minQueryIntervalSeconds: clampInt(
|
|
127
|
+
merged.minQueryIntervalSeconds,
|
|
128
|
+
DEFAULT_CONFIG.minQueryIntervalSeconds,
|
|
129
|
+
5,
|
|
130
|
+
300,
|
|
131
|
+
),
|
|
120
132
|
};
|
|
121
133
|
}
|
|
122
134
|
|
package/index.ts
CHANGED
|
@@ -312,10 +312,17 @@ The user sees the overlay and can:
|
|
|
312
312
|
- Kill/background via double-Escape
|
|
313
313
|
|
|
314
314
|
QUERYING SESSION STATUS:
|
|
315
|
-
- interactive_shell({ sessionId: "calm-reef" }) - get status +
|
|
315
|
+
- interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (last 20 lines)
|
|
316
316
|
- interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
|
|
317
317
|
- interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
|
|
318
318
|
|
|
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.
|
|
321
|
+
|
|
322
|
+
RATE LIMITING:
|
|
323
|
+
Queries are limited to once every 60 seconds (configurable). If you query too soon,
|
|
324
|
+
the tool will automatically wait until the limit expires before returning.
|
|
325
|
+
|
|
319
326
|
SENDING INPUT:
|
|
320
327
|
- interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
|
|
321
328
|
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
@@ -434,7 +441,7 @@ Examples:
|
|
|
434
441
|
),
|
|
435
442
|
autoExitOnQuiet: Type.Optional(
|
|
436
443
|
Type.Boolean({
|
|
437
|
-
description: "Auto-kill session when output stops (after quietThreshold).
|
|
444
|
+
description: "Auto-kill session when output stops (after quietThreshold). Defaults to true in hands-free mode. Set to false to keep session alive indefinitely.",
|
|
438
445
|
}),
|
|
439
446
|
),
|
|
440
447
|
}),
|
|
@@ -513,7 +520,7 @@ Examples:
|
|
|
513
520
|
|
|
514
521
|
// Kill session if requested
|
|
515
522
|
if (kill) {
|
|
516
|
-
const { output, truncated, totalBytes } = session.getOutput();
|
|
523
|
+
const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true for kill
|
|
517
524
|
const status = session.getStatus();
|
|
518
525
|
const runtime = session.getRuntime();
|
|
519
526
|
session.kill();
|
|
@@ -589,16 +596,17 @@ Examples:
|
|
|
589
596
|
|
|
590
597
|
// If only querying status (no input, no settings, no kill)
|
|
591
598
|
if (actions.length === 0) {
|
|
592
|
-
const { output, truncated, totalBytes } = session.getOutput();
|
|
593
599
|
const status = session.getStatus();
|
|
594
600
|
const runtime = session.getRuntime();
|
|
595
601
|
const result = session.getResult();
|
|
596
602
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
// Check if session completed
|
|
603
|
+
// If session completed, always allow query (no rate limiting)
|
|
604
|
+
// Rate limiting only applies to "checking in" on running sessions
|
|
601
605
|
if (result) {
|
|
606
|
+
const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true
|
|
607
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
608
|
+
const hasOutput = output.length > 0;
|
|
609
|
+
|
|
602
610
|
sessionManager.unregisterActive(sessionId, true);
|
|
603
611
|
return {
|
|
604
612
|
content: [
|
|
@@ -621,11 +629,138 @@ Examples:
|
|
|
621
629
|
};
|
|
622
630
|
}
|
|
623
631
|
|
|
632
|
+
// Session still running - check rate limiting
|
|
633
|
+
const outputResult = session.getOutput();
|
|
634
|
+
|
|
635
|
+
// If rate limited, wait until allowed then return fresh result
|
|
636
|
+
// Use Promise.race to detect if session completes during wait
|
|
637
|
+
if (outputResult.rateLimited && outputResult.waitSeconds) {
|
|
638
|
+
const waitMs = outputResult.waitSeconds * 1000;
|
|
639
|
+
|
|
640
|
+
// Race: rate limit timeout vs session completion
|
|
641
|
+
const completedEarly = await Promise.race([
|
|
642
|
+
new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
|
|
643
|
+
new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
|
|
644
|
+
]);
|
|
645
|
+
|
|
646
|
+
// If session completed during wait, return result immediately
|
|
647
|
+
if (completedEarly) {
|
|
648
|
+
const earlySession = sessionManager.getActive(sessionId);
|
|
649
|
+
if (!earlySession) {
|
|
650
|
+
return {
|
|
651
|
+
content: [{ type: "text", text: `Session ${sessionId} ended` }],
|
|
652
|
+
details: { sessionId, status: "ended" },
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const earlyResult = earlySession.getResult();
|
|
656
|
+
const { output, truncated, totalBytes } = earlySession.getOutput(true); // skipRateLimit
|
|
657
|
+
const earlyStatus = earlySession.getStatus();
|
|
658
|
+
const earlyRuntime = earlySession.getRuntime();
|
|
659
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
660
|
+
const hasOutput = output.length > 0;
|
|
661
|
+
|
|
662
|
+
if (earlyResult) {
|
|
663
|
+
sessionManager.unregisterActive(sessionId, true);
|
|
664
|
+
return {
|
|
665
|
+
content: [
|
|
666
|
+
{
|
|
667
|
+
type: "text",
|
|
668
|
+
text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
details: {
|
|
672
|
+
sessionId,
|
|
673
|
+
status: earlyStatus,
|
|
674
|
+
runtime: earlyRuntime,
|
|
675
|
+
output,
|
|
676
|
+
outputTruncated: truncated,
|
|
677
|
+
outputTotalBytes: totalBytes,
|
|
678
|
+
exitCode: earlyResult.exitCode,
|
|
679
|
+
signal: earlyResult.signal,
|
|
680
|
+
backgroundId: earlyResult.backgroundId,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
// Edge case: onComplete fired but no result yet (shouldn't happen)
|
|
685
|
+
// Return current status without unregistering
|
|
686
|
+
return {
|
|
687
|
+
content: [
|
|
688
|
+
{
|
|
689
|
+
type: "text",
|
|
690
|
+
text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
details: {
|
|
694
|
+
sessionId,
|
|
695
|
+
status: earlyStatus,
|
|
696
|
+
runtime: earlyRuntime,
|
|
697
|
+
output,
|
|
698
|
+
outputTruncated: truncated,
|
|
699
|
+
outputTotalBytes: totalBytes,
|
|
700
|
+
hasOutput,
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
// Get fresh output after waiting
|
|
705
|
+
const freshOutput = session.getOutput();
|
|
706
|
+
const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
|
|
707
|
+
const hasOutput = freshOutput.output.length > 0;
|
|
708
|
+
const freshStatus = session.getStatus();
|
|
709
|
+
const freshRuntime = session.getRuntime();
|
|
710
|
+
const freshResult = session.getResult();
|
|
711
|
+
|
|
712
|
+
if (freshResult) {
|
|
713
|
+
sessionManager.unregisterActive(sessionId, true);
|
|
714
|
+
return {
|
|
715
|
+
content: [
|
|
716
|
+
{
|
|
717
|
+
type: "text",
|
|
718
|
+
text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
details: {
|
|
722
|
+
sessionId,
|
|
723
|
+
status: freshStatus,
|
|
724
|
+
runtime: freshRuntime,
|
|
725
|
+
output: freshOutput.output,
|
|
726
|
+
outputTruncated: freshOutput.truncated,
|
|
727
|
+
outputTotalBytes: freshOutput.totalBytes,
|
|
728
|
+
exitCode: freshResult.exitCode,
|
|
729
|
+
signal: freshResult.signal,
|
|
730
|
+
backgroundId: freshResult.backgroundId,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
content: [
|
|
737
|
+
{
|
|
738
|
+
type: "text",
|
|
739
|
+
text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
details: {
|
|
743
|
+
sessionId,
|
|
744
|
+
status: freshStatus,
|
|
745
|
+
runtime: freshRuntime,
|
|
746
|
+
output: freshOutput.output,
|
|
747
|
+
outputTruncated: freshOutput.truncated,
|
|
748
|
+
outputTotalBytes: freshOutput.totalBytes,
|
|
749
|
+
hasOutput,
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const { output, truncated, totalBytes } = outputResult;
|
|
755
|
+
|
|
756
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
757
|
+
const hasOutput = output.length > 0;
|
|
758
|
+
|
|
624
759
|
return {
|
|
625
760
|
content: [
|
|
626
761
|
{
|
|
627
762
|
type: "text",
|
|
628
|
-
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\
|
|
763
|
+
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
629
764
|
},
|
|
630
765
|
],
|
|
631
766
|
details: {
|
|
@@ -635,7 +770,7 @@ Examples:
|
|
|
635
770
|
output,
|
|
636
771
|
outputTruncated: truncated,
|
|
637
772
|
outputTotalBytes: totalBytes,
|
|
638
|
-
|
|
773
|
+
hasOutput,
|
|
639
774
|
},
|
|
640
775
|
};
|
|
641
776
|
}
|
|
@@ -696,7 +831,8 @@ Examples:
|
|
|
696
831
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
697
832
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
698
833
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
699
|
-
autoExitOnQuiet
|
|
834
|
+
// Default autoExitOnQuiet to true in hands-free mode
|
|
835
|
+
autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
|
|
700
836
|
// No onHandsFreeUpdate in non-blocking mode - agent queries directly
|
|
701
837
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
702
838
|
handoffPreviewLines: handoffPreview?.lines,
|
package/overlay-component.ts
CHANGED
|
@@ -122,9 +122,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
122
122
|
private lastDataTime = 0;
|
|
123
123
|
private quietTimer: ReturnType<typeof setTimeout> | null = null;
|
|
124
124
|
private hasUnsentData = false;
|
|
125
|
-
// Non-blocking mode: track status
|
|
126
|
-
private agentOutputPosition = 0; // Track last read position for incremental output
|
|
125
|
+
// Non-blocking mode: track status for agent queries
|
|
127
126
|
private completionResult: InteractiveShellResult | undefined;
|
|
127
|
+
// Rate limiting for queries
|
|
128
|
+
private lastQueryTime = 0;
|
|
129
|
+
// Completion callbacks for waiters
|
|
130
|
+
private completeCallbacks: Array<() => void> = [];
|
|
128
131
|
|
|
129
132
|
constructor(
|
|
130
133
|
tui: TUI,
|
|
@@ -222,12 +225,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
222
225
|
reason: options.reason,
|
|
223
226
|
write: (data) => this.session.write(data),
|
|
224
227
|
kill: () => this.killSession(),
|
|
225
|
-
getOutput: () => this.getOutputSinceLastCheck(),
|
|
228
|
+
getOutput: (skipRateLimit) => this.getOutputSinceLastCheck(skipRateLimit),
|
|
226
229
|
getStatus: () => this.getSessionStatus(),
|
|
227
230
|
getRuntime: () => this.getRuntime(),
|
|
228
231
|
getResult: () => this.getCompletionResult(),
|
|
229
232
|
setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
|
|
230
233
|
setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
|
|
234
|
+
onComplete: (callback) => this.registerCompleteCallback(callback),
|
|
231
235
|
});
|
|
232
236
|
this.startHandsFreeUpdates();
|
|
233
237
|
}
|
|
@@ -242,33 +246,47 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
242
246
|
|
|
243
247
|
// Public methods for non-blocking mode (agent queries)
|
|
244
248
|
|
|
245
|
-
// Max output per status query (
|
|
246
|
-
private static readonly MAX_STATUS_OUTPUT =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
249
|
+
// Max output per status query (5KB) - prevents overwhelming agent context
|
|
250
|
+
private static readonly MAX_STATUS_OUTPUT = 5 * 1024;
|
|
251
|
+
// Max lines to return per query - keep small, we're just checking in
|
|
252
|
+
private static readonly MAX_STATUS_LINES = 20;
|
|
253
|
+
|
|
254
|
+
/** Get rendered terminal output (last N lines, truncated if too large) */
|
|
255
|
+
getOutputSinceLastCheck(skipRateLimit = false): { output: string; truncated: boolean; totalBytes: number; rateLimited?: boolean; waitSeconds?: number } {
|
|
256
|
+
// Check rate limiting (unless skipped, e.g., for completed sessions)
|
|
257
|
+
if (!skipRateLimit) {
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
|
|
260
|
+
const elapsed = now - this.lastQueryTime;
|
|
261
|
+
|
|
262
|
+
if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
|
|
263
|
+
const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
|
|
264
|
+
return {
|
|
265
|
+
output: "",
|
|
266
|
+
truncated: false,
|
|
267
|
+
totalBytes: 0,
|
|
268
|
+
rateLimited: true,
|
|
269
|
+
waitSeconds,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Update last query time
|
|
274
|
+
this.lastQueryTime = now;
|
|
269
275
|
}
|
|
270
276
|
|
|
271
|
-
|
|
277
|
+
// Use rendered terminal output instead of raw stream
|
|
278
|
+
// This gives clean, readable content without TUI animation garbage
|
|
279
|
+
const lines = this.session.getTailLines({
|
|
280
|
+
lines: InteractiveShellOverlay.MAX_STATUS_LINES,
|
|
281
|
+
ansi: false,
|
|
282
|
+
maxChars: InteractiveShellOverlay.MAX_STATUS_OUTPUT,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const output = lines.join("\n");
|
|
286
|
+
const totalBytes = output.length;
|
|
287
|
+
const truncated = lines.length >= InteractiveShellOverlay.MAX_STATUS_LINES;
|
|
288
|
+
|
|
289
|
+
return { output, truncated, totalBytes };
|
|
272
290
|
}
|
|
273
291
|
|
|
274
292
|
/** Get current session status */
|
|
@@ -294,6 +312,28 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
294
312
|
return this.completionResult;
|
|
295
313
|
}
|
|
296
314
|
|
|
315
|
+
/** Register a callback to be called when session completes */
|
|
316
|
+
registerCompleteCallback(callback: () => void): void {
|
|
317
|
+
// If already completed, call immediately
|
|
318
|
+
if (this.completionResult) {
|
|
319
|
+
callback();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.completeCallbacks.push(callback);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Trigger all completion callbacks */
|
|
326
|
+
private triggerCompleteCallbacks(): void {
|
|
327
|
+
for (const callback of this.completeCallbacks) {
|
|
328
|
+
try {
|
|
329
|
+
callback();
|
|
330
|
+
} catch {
|
|
331
|
+
// Ignore errors in callbacks
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
this.completeCallbacks = [];
|
|
335
|
+
}
|
|
336
|
+
|
|
297
337
|
/** Get the session ID */
|
|
298
338
|
getSessionId(): string | null {
|
|
299
339
|
return this.sessionId;
|
|
@@ -366,9 +406,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
366
406
|
this.hasUnsentData = false;
|
|
367
407
|
}
|
|
368
408
|
// Send completion notification and auto-close
|
|
409
|
+
// Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
|
|
369
410
|
if (this.options.onHandsFreeUpdate && this.sessionId) {
|
|
370
411
|
this.options.onHandsFreeUpdate({
|
|
371
|
-
status: "
|
|
412
|
+
status: "killed",
|
|
372
413
|
sessionId: this.sessionId,
|
|
373
414
|
runtime: Date.now() - this.startTime,
|
|
374
415
|
tail: [],
|
|
@@ -639,6 +680,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
639
680
|
handoff,
|
|
640
681
|
};
|
|
641
682
|
this.completionResult = result;
|
|
683
|
+
this.triggerCompleteCallbacks();
|
|
642
684
|
|
|
643
685
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
644
686
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -671,6 +713,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
671
713
|
handoff,
|
|
672
714
|
};
|
|
673
715
|
this.completionResult = result;
|
|
716
|
+
this.triggerCompleteCallbacks();
|
|
674
717
|
|
|
675
718
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
676
719
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -702,6 +745,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
702
745
|
handoff,
|
|
703
746
|
};
|
|
704
747
|
this.completionResult = result;
|
|
748
|
+
this.triggerCompleteCallbacks();
|
|
705
749
|
|
|
706
750
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
707
751
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -754,6 +798,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
754
798
|
handoff,
|
|
755
799
|
};
|
|
756
800
|
this.completionResult = result;
|
|
801
|
+
this.triggerCompleteCallbacks();
|
|
757
802
|
|
|
758
803
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
759
804
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -978,10 +1023,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
978
1023
|
this.stopTimeout();
|
|
979
1024
|
this.stopHandsFreeUpdates();
|
|
980
1025
|
// Safety cleanup in case dispose() is called without going through finishWith*
|
|
981
|
-
//
|
|
982
|
-
if (!this.completionResult
|
|
1026
|
+
// If session hasn't completed yet, kill it to prevent orphaned processes
|
|
1027
|
+
if (!this.completionResult) {
|
|
1028
|
+
this.session.kill();
|
|
1029
|
+
this.session.dispose();
|
|
1030
|
+
this.unregisterActiveSession();
|
|
1031
|
+
} else if (this.options.onHandsFreeUpdate) {
|
|
1032
|
+
// Streaming mode already delivered result, safe to unregister
|
|
983
1033
|
this.unregisterActiveSession();
|
|
984
1034
|
}
|
|
1035
|
+
// Non-blocking mode with completion: keep registered so agent can query
|
|
985
1036
|
}
|
|
986
1037
|
}
|
|
987
1038
|
|
package/package.json
CHANGED
package/session-manager.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface OutputResult {
|
|
|
24
24
|
output: string;
|
|
25
25
|
truncated: boolean;
|
|
26
26
|
totalBytes: number;
|
|
27
|
+
// Rate limiting
|
|
28
|
+
rateLimited?: boolean;
|
|
29
|
+
waitSeconds?: number;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export interface ActiveSession {
|
|
@@ -32,12 +35,13 @@ export interface ActiveSession {
|
|
|
32
35
|
reason?: string;
|
|
33
36
|
write: (data: string) => void;
|
|
34
37
|
kill: () => void;
|
|
35
|
-
getOutput: () => OutputResult; // Get output since last check (truncated if large)
|
|
38
|
+
getOutput: (skipRateLimit?: boolean) => OutputResult; // Get output since last check (truncated if large)
|
|
36
39
|
getStatus: () => ActiveSessionStatus;
|
|
37
40
|
getRuntime: () => number;
|
|
38
41
|
getResult: () => ActiveSessionResult | undefined; // Available when completed
|
|
39
42
|
setUpdateInterval?: (intervalMs: number) => void;
|
|
40
43
|
setQuietThreshold?: (thresholdMs: number) => void;
|
|
44
|
+
onComplete: (callback: () => void) => void; // Register callback for when session completes
|
|
41
45
|
startedAt: Date;
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -129,12 +133,13 @@ export class ShellSessionManager {
|
|
|
129
133
|
reason?: string;
|
|
130
134
|
write: (data: string) => void;
|
|
131
135
|
kill: () => void;
|
|
132
|
-
getOutput: () => OutputResult;
|
|
136
|
+
getOutput: (skipRateLimit?: boolean) => OutputResult;
|
|
133
137
|
getStatus: () => ActiveSessionStatus;
|
|
134
138
|
getRuntime: () => number;
|
|
135
139
|
getResult: () => ActiveSessionResult | undefined;
|
|
136
140
|
setUpdateInterval?: (intervalMs: number) => void;
|
|
137
141
|
setQuietThreshold?: (thresholdMs: number) => void;
|
|
142
|
+
onComplete: (callback: () => void) => void;
|
|
138
143
|
}): void {
|
|
139
144
|
this.activeSessions.set(session.id, {
|
|
140
145
|
...session,
|
|
@@ -259,13 +264,15 @@ export class ShellSessionManager {
|
|
|
259
264
|
for (const [id, session] of activeEntries) {
|
|
260
265
|
try {
|
|
261
266
|
session.kill();
|
|
267
|
+
// Only release ID if kill succeeded - let natural cleanup handle failures
|
|
268
|
+
// The session's exit handler will call unregisterActive() which releases the ID
|
|
262
269
|
} catch {
|
|
263
|
-
// Session may already be dead
|
|
270
|
+
// Session may already be dead - still safe to release since no process running
|
|
271
|
+
releaseSessionId(id);
|
|
264
272
|
}
|
|
265
|
-
// Release ID if not already released by kill()
|
|
266
|
-
releaseSessionId(id);
|
|
267
273
|
}
|
|
268
|
-
|
|
274
|
+
// Don't clear immediately - let unregisterActive() handle cleanup as sessions exit
|
|
275
|
+
// This prevents ID reuse while processes are still terminating
|
|
269
276
|
}
|
|
270
277
|
}
|
|
271
278
|
|