pi-interactive-shell 0.4.1 → 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 +17 -0
- package/README.md +1 -1
- package/SKILL.md +3 -1
- package/config.ts +15 -3
- package/index.ts +143 -10
- package/overlay-component.ts +64 -5
- package/package.json +1 -1
- package/session-manager.ts +13 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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
|
+
|
|
5
22
|
## [0.4.1] - 2026-01-17
|
|
6
23
|
|
|
7
24
|
### Changed
|
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
|
@@ -113,7 +113,7 @@ Returns:
|
|
|
113
113
|
- `output`: Last 20 lines of rendered terminal (clean, no TUI animation noise)
|
|
114
114
|
- `runtime`: Time elapsed in ms
|
|
115
115
|
|
|
116
|
-
**
|
|
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
117
|
|
|
118
118
|
### Ending a Session
|
|
119
119
|
```typescript
|
|
@@ -122,6 +122,8 @@ interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
|
122
122
|
|
|
123
123
|
Kill when you see the task is complete in the output. Returns final status and output.
|
|
124
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
|
+
|
|
125
127
|
### Sending Input
|
|
126
128
|
```typescript
|
|
127
129
|
interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
|
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
|
@@ -319,6 +319,10 @@ QUERYING SESSION STATUS:
|
|
|
319
319
|
IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
|
|
320
320
|
The user is watching the overlay in real-time - you're just checking in periodically.
|
|
321
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
|
+
|
|
322
326
|
SENDING INPUT:
|
|
323
327
|
- interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
|
|
324
328
|
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
@@ -437,7 +441,7 @@ Examples:
|
|
|
437
441
|
),
|
|
438
442
|
autoExitOnQuiet: Type.Optional(
|
|
439
443
|
Type.Boolean({
|
|
440
|
-
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.",
|
|
441
445
|
}),
|
|
442
446
|
),
|
|
443
447
|
}),
|
|
@@ -516,7 +520,7 @@ Examples:
|
|
|
516
520
|
|
|
517
521
|
// Kill session if requested
|
|
518
522
|
if (kill) {
|
|
519
|
-
const { output, truncated, totalBytes } = session.getOutput();
|
|
523
|
+
const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true for kill
|
|
520
524
|
const status = session.getStatus();
|
|
521
525
|
const runtime = session.getRuntime();
|
|
522
526
|
session.kill();
|
|
@@ -592,16 +596,17 @@ Examples:
|
|
|
592
596
|
|
|
593
597
|
// If only querying status (no input, no settings, no kill)
|
|
594
598
|
if (actions.length === 0) {
|
|
595
|
-
const { output, truncated, totalBytes } = session.getOutput();
|
|
596
599
|
const status = session.getStatus();
|
|
597
600
|
const runtime = session.getRuntime();
|
|
598
601
|
const result = session.getResult();
|
|
599
602
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// 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
|
|
604
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
|
+
|
|
605
610
|
sessionManager.unregisterActive(sessionId, true);
|
|
606
611
|
return {
|
|
607
612
|
content: [
|
|
@@ -624,11 +629,138 @@ Examples:
|
|
|
624
629
|
};
|
|
625
630
|
}
|
|
626
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
|
+
|
|
627
759
|
return {
|
|
628
760
|
content: [
|
|
629
761
|
{
|
|
630
762
|
type: "text",
|
|
631
|
-
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\
|
|
763
|
+
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
632
764
|
},
|
|
633
765
|
],
|
|
634
766
|
details: {
|
|
@@ -638,7 +770,7 @@ Examples:
|
|
|
638
770
|
output,
|
|
639
771
|
outputTruncated: truncated,
|
|
640
772
|
outputTotalBytes: totalBytes,
|
|
641
|
-
|
|
773
|
+
hasOutput,
|
|
642
774
|
},
|
|
643
775
|
};
|
|
644
776
|
}
|
|
@@ -699,7 +831,8 @@ Examples:
|
|
|
699
831
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
700
832
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
701
833
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
702
|
-
autoExitOnQuiet
|
|
834
|
+
// Default autoExitOnQuiet to true in hands-free mode
|
|
835
|
+
autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
|
|
703
836
|
// No onHandsFreeUpdate in non-blocking mode - agent queries directly
|
|
704
837
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
705
838
|
handoffPreviewLines: handoffPreview?.lines,
|
package/overlay-component.ts
CHANGED
|
@@ -124,6 +124,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
124
124
|
private hasUnsentData = false;
|
|
125
125
|
// Non-blocking mode: track status for agent queries
|
|
126
126
|
private completionResult: InteractiveShellResult | undefined;
|
|
127
|
+
// Rate limiting for queries
|
|
128
|
+
private lastQueryTime = 0;
|
|
129
|
+
// Completion callbacks for waiters
|
|
130
|
+
private completeCallbacks: Array<() => void> = [];
|
|
127
131
|
|
|
128
132
|
constructor(
|
|
129
133
|
tui: TUI,
|
|
@@ -221,12 +225,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
221
225
|
reason: options.reason,
|
|
222
226
|
write: (data) => this.session.write(data),
|
|
223
227
|
kill: () => this.killSession(),
|
|
224
|
-
getOutput: () => this.getOutputSinceLastCheck(),
|
|
228
|
+
getOutput: (skipRateLimit) => this.getOutputSinceLastCheck(skipRateLimit),
|
|
225
229
|
getStatus: () => this.getSessionStatus(),
|
|
226
230
|
getRuntime: () => this.getRuntime(),
|
|
227
231
|
getResult: () => this.getCompletionResult(),
|
|
228
232
|
setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
|
|
229
233
|
setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
|
|
234
|
+
onComplete: (callback) => this.registerCompleteCallback(callback),
|
|
230
235
|
});
|
|
231
236
|
this.startHandsFreeUpdates();
|
|
232
237
|
}
|
|
@@ -247,7 +252,28 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
247
252
|
private static readonly MAX_STATUS_LINES = 20;
|
|
248
253
|
|
|
249
254
|
/** Get rendered terminal output (last N lines, truncated if too large) */
|
|
250
|
-
getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: number } {
|
|
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;
|
|
275
|
+
}
|
|
276
|
+
|
|
251
277
|
// Use rendered terminal output instead of raw stream
|
|
252
278
|
// This gives clean, readable content without TUI animation garbage
|
|
253
279
|
const lines = this.session.getTailLines({
|
|
@@ -286,6 +312,28 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
286
312
|
return this.completionResult;
|
|
287
313
|
}
|
|
288
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
|
+
|
|
289
337
|
/** Get the session ID */
|
|
290
338
|
getSessionId(): string | null {
|
|
291
339
|
return this.sessionId;
|
|
@@ -358,9 +406,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
358
406
|
this.hasUnsentData = false;
|
|
359
407
|
}
|
|
360
408
|
// Send completion notification and auto-close
|
|
409
|
+
// Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
|
|
361
410
|
if (this.options.onHandsFreeUpdate && this.sessionId) {
|
|
362
411
|
this.options.onHandsFreeUpdate({
|
|
363
|
-
status: "
|
|
412
|
+
status: "killed",
|
|
364
413
|
sessionId: this.sessionId,
|
|
365
414
|
runtime: Date.now() - this.startTime,
|
|
366
415
|
tail: [],
|
|
@@ -631,6 +680,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
631
680
|
handoff,
|
|
632
681
|
};
|
|
633
682
|
this.completionResult = result;
|
|
683
|
+
this.triggerCompleteCallbacks();
|
|
634
684
|
|
|
635
685
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
636
686
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -663,6 +713,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
663
713
|
handoff,
|
|
664
714
|
};
|
|
665
715
|
this.completionResult = result;
|
|
716
|
+
this.triggerCompleteCallbacks();
|
|
666
717
|
|
|
667
718
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
668
719
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -694,6 +745,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
694
745
|
handoff,
|
|
695
746
|
};
|
|
696
747
|
this.completionResult = result;
|
|
748
|
+
this.triggerCompleteCallbacks();
|
|
697
749
|
|
|
698
750
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
699
751
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -746,6 +798,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
746
798
|
handoff,
|
|
747
799
|
};
|
|
748
800
|
this.completionResult = result;
|
|
801
|
+
this.triggerCompleteCallbacks();
|
|
749
802
|
|
|
750
803
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
751
804
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -970,10 +1023,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
970
1023
|
this.stopTimeout();
|
|
971
1024
|
this.stopHandsFreeUpdates();
|
|
972
1025
|
// Safety cleanup in case dispose() is called without going through finishWith*
|
|
973
|
-
//
|
|
974
|
-
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
|
|
975
1033
|
this.unregisterActiveSession();
|
|
976
1034
|
}
|
|
1035
|
+
// Non-blocking mode with completion: keep registered so agent can query
|
|
977
1036
|
}
|
|
978
1037
|
}
|
|
979
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
|
|