pi-interactive-shell 0.9.0 → 0.10.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 +27 -0
- package/README.md +21 -13
- package/SKILL.md +2 -2
- package/background-widget.ts +76 -0
- package/examples/prompts/codex-implement-plan.md +11 -3
- package/examples/prompts/codex-review-impl.md +11 -3
- package/examples/prompts/codex-review-plan.md +11 -3
- package/examples/skills/{codex-5.3-prompting → codex-5-3-prompting}/SKILL.md +1 -1
- package/examples/skills/codex-cli/SKILL.md +11 -5
- package/examples/skills/gpt-5-4-prompting/SKILL.md +202 -0
- package/handoff-utils.ts +92 -0
- package/headless-monitor.ts +6 -1
- package/index.ts +231 -416
- package/notification-utils.ts +134 -0
- package/overlay-component.ts +14 -213
- package/package.json +26 -6
- package/pty-log.ts +59 -0
- package/pty-protocol.ts +33 -0
- package/pty-session.ts +11 -134
- package/reattach-overlay.ts +5 -74
- package/runtime-coordinator.ts +69 -0
- package/scripts/install.js +5 -1
- package/session-manager.ts +21 -11
- package/session-query.ts +170 -0
- package/spawn-helper.ts +37 -0
- package/types.ts +3 -0
package/pty-session.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { chmodSync, statSync } from "node:fs";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
1
|
import { stripVTControlCharacters } from "node:util";
|
|
5
2
|
import * as pty from "node-pty";
|
|
6
3
|
import type { IBufferCell, Terminal as XtermTerminal } from "@xterm/headless";
|
|
7
4
|
import xterm from "@xterm/headless";
|
|
8
5
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
6
|
+
import { sliceLogOutput, trimRawOutput } from "./pty-log.js";
|
|
7
|
+
import { splitAroundDsr, buildCursorPositionResponse } from "./pty-protocol.js";
|
|
8
|
+
import { ensureSpawnHelperExec } from "./spawn-helper.js";
|
|
9
9
|
|
|
10
10
|
const Terminal = xterm.Terminal;
|
|
11
|
-
const require = createRequire(import.meta.url);
|
|
12
|
-
let spawnHelperChecked = false;
|
|
13
11
|
|
|
14
12
|
// Regex patterns for sanitizing terminal output (used by sanitizeLine for viewport rendering)
|
|
15
13
|
const OSC_REGEX = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
|
|
@@ -19,79 +17,6 @@ const CSI_REGEX = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
|
19
17
|
const ESC_SINGLE_REGEX = /\x1b[@-_]/g;
|
|
20
18
|
const CONTROL_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g;
|
|
21
19
|
|
|
22
|
-
// DSR (Device Status Report) - cursor position query: ESC[6n or ESC[?6n
|
|
23
|
-
const DSR_PATTERN = /\x1b\[\??6n/g;
|
|
24
|
-
|
|
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 };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function buildCursorPositionResponse(row = 1, col = 1): string {
|
|
62
|
-
return `\x1b[${row};${col}R`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function ensureSpawnHelperExec(): void {
|
|
66
|
-
if (spawnHelperChecked) return;
|
|
67
|
-
spawnHelperChecked = true;
|
|
68
|
-
if (process.platform !== "darwin") return;
|
|
69
|
-
|
|
70
|
-
let pkgPath: string;
|
|
71
|
-
try {
|
|
72
|
-
pkgPath = require.resolve("node-pty/package.json");
|
|
73
|
-
} catch {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const base = dirname(pkgPath);
|
|
78
|
-
const targets = [
|
|
79
|
-
join(base, "prebuilds", "darwin-arm64", "spawn-helper"),
|
|
80
|
-
join(base, "prebuilds", "darwin-x64", "spawn-helper"),
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
for (const target of targets) {
|
|
84
|
-
try {
|
|
85
|
-
const stats = statSync(target);
|
|
86
|
-
const mode = stats.mode | 0o111;
|
|
87
|
-
if ((stats.mode & 0o111) !== 0o111) {
|
|
88
|
-
chmodSync(target, mode);
|
|
89
|
-
}
|
|
90
|
-
} catch {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
20
|
|
|
96
21
|
function sanitizeLine(line: string): string {
|
|
97
22
|
let out = line;
|
|
@@ -234,13 +159,9 @@ export class PtyTerminalSession {
|
|
|
234
159
|
|
|
235
160
|
// Trim raw output buffer if it exceeds max size
|
|
236
161
|
private trimRawOutputIfNeeded(): void {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
this.rawOutput = this.rawOutput.substring(trimAmount);
|
|
241
|
-
// Adjust stream position to account for trimmed content
|
|
242
|
-
this.lastStreamPosition = Math.max(0, this.lastStreamPosition - trimAmount);
|
|
243
|
-
}
|
|
162
|
+
const trimmed = trimRawOutput(this.rawOutput, this.lastStreamPosition);
|
|
163
|
+
this.rawOutput = trimmed.rawOutput;
|
|
164
|
+
this.lastStreamPosition = trimmed.lastStreamPosition;
|
|
244
165
|
}
|
|
245
166
|
|
|
246
167
|
constructor(options: PtySessionOptions, events: PtySessionEvents = {}) {
|
|
@@ -367,14 +288,16 @@ export class PtyTerminalSession {
|
|
|
367
288
|
|
|
368
289
|
private notifyDataListeners(data: string): void {
|
|
369
290
|
this.dataHandler?.(data);
|
|
370
|
-
|
|
291
|
+
// Copy array to avoid issues if a listener unsubscribes during iteration
|
|
292
|
+
for (const listener of [...this.additionalDataListeners]) {
|
|
371
293
|
listener(data);
|
|
372
294
|
}
|
|
373
295
|
}
|
|
374
296
|
|
|
375
297
|
private notifyExitListeners(exitCode: number, signal?: number): void {
|
|
376
298
|
this.exitHandler?.(exitCode, signal);
|
|
377
|
-
|
|
299
|
+
// Copy array to avoid issues if a listener unsubscribes during iteration
|
|
300
|
+
for (const listener of [...this.additionalExitListeners]) {
|
|
378
301
|
listener(exitCode, signal);
|
|
379
302
|
}
|
|
380
303
|
}
|
|
@@ -626,53 +549,7 @@ export class PtyTerminalSession {
|
|
|
626
549
|
totalChars: number;
|
|
627
550
|
sliceLineCount: number;
|
|
628
551
|
} {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
// Strip ANSI by default
|
|
632
|
-
if (options.stripAnsi !== false && text) {
|
|
633
|
-
text = stripVTControlCharacters(text);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (!text) {
|
|
637
|
-
return { slice: "", totalLines: 0, totalChars: 0, sliceLineCount: 0 };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Normalize line endings and split
|
|
641
|
-
const normalized = text.replace(/\r\n/g, "\n");
|
|
642
|
-
const lines = normalized.split("\n");
|
|
643
|
-
// Remove trailing empty line from split
|
|
644
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
645
|
-
lines.pop();
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const totalLines = lines.length;
|
|
649
|
-
const totalChars = text.length;
|
|
650
|
-
|
|
651
|
-
// Calculate start position
|
|
652
|
-
let start: number;
|
|
653
|
-
if (typeof options.offset === "number" && Number.isFinite(options.offset)) {
|
|
654
|
-
start = Math.max(0, Math.floor(options.offset));
|
|
655
|
-
} else if (options.limit !== undefined) {
|
|
656
|
-
// No offset but limit provided - return tail (last N lines)
|
|
657
|
-
const tailCount = Math.max(0, Math.floor(options.limit));
|
|
658
|
-
start = Math.max(totalLines - tailCount, 0);
|
|
659
|
-
} else {
|
|
660
|
-
start = 0;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// Calculate end position
|
|
664
|
-
const end = typeof options.limit === "number" && Number.isFinite(options.limit)
|
|
665
|
-
? start + Math.max(0, Math.floor(options.limit))
|
|
666
|
-
: undefined;
|
|
667
|
-
|
|
668
|
-
const selectedLines = lines.slice(start, end);
|
|
669
|
-
|
|
670
|
-
return {
|
|
671
|
-
slice: selectedLines.join("\n"),
|
|
672
|
-
totalLines,
|
|
673
|
-
totalChars,
|
|
674
|
-
sliceLineCount: selectedLines.length,
|
|
675
|
-
};
|
|
552
|
+
return sliceLogOutput(this.rawOutput, options);
|
|
676
553
|
}
|
|
677
554
|
|
|
678
555
|
scrollUp(lines: number): void {
|
package/reattach-overlay.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
1
|
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
|
|
4
2
|
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
5
3
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
7
4
|
import { PtyTerminalSession } from "./pty-session.js";
|
|
8
5
|
import { sessionManager } from "./session-manager.js";
|
|
9
6
|
import type { InteractiveShellConfig } from "./config.js";
|
|
@@ -15,6 +12,7 @@ import {
|
|
|
15
12
|
FOOTER_LINES_COMPACT,
|
|
16
13
|
FOOTER_LINES_DIALOG,
|
|
17
14
|
} from "./types.js";
|
|
15
|
+
import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
|
|
18
16
|
|
|
19
17
|
export class ReattachOverlay implements Component, Focusable {
|
|
20
18
|
focused = false;
|
|
@@ -103,87 +101,20 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
|
|
106
|
-
|
|
107
|
-
lines: this.config.completionNotifyLines,
|
|
108
|
-
ansi: false,
|
|
109
|
-
maxChars: this.config.completionNotifyMaxChars,
|
|
110
|
-
});
|
|
111
|
-
return {
|
|
112
|
-
lines: result.lines,
|
|
113
|
-
totalLines: result.totalLinesInBuffer,
|
|
114
|
-
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
|
|
115
|
-
};
|
|
104
|
+
return captureCompletionOutput(this.session, this.config);
|
|
116
105
|
}
|
|
117
106
|
|
|
118
107
|
/** Capture output for transfer action (Ctrl+T or dialog) */
|
|
119
108
|
private captureTransferOutput(): InteractiveShellResult["transferred"] {
|
|
120
|
-
|
|
121
|
-
const maxChars = this.config.transferMaxChars;
|
|
122
|
-
|
|
123
|
-
const result = this.session.getTailLines({
|
|
124
|
-
lines: maxLines,
|
|
125
|
-
ansi: false,
|
|
126
|
-
maxChars,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
lines: result.lines,
|
|
133
|
-
totalLines: result.totalLinesInBuffer,
|
|
134
|
-
truncated,
|
|
135
|
-
};
|
|
109
|
+
return captureTransferOutput(this.session, this.config);
|
|
136
110
|
}
|
|
137
111
|
|
|
138
112
|
private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
|
|
139
|
-
|
|
140
|
-
const lines = this.config.handoffPreviewLines;
|
|
141
|
-
const maxChars = this.config.handoffPreviewMaxChars;
|
|
142
|
-
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
143
|
-
|
|
144
|
-
const result = this.session.getTailLines({
|
|
145
|
-
lines,
|
|
146
|
-
ansi: false,
|
|
147
|
-
maxChars,
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
return { type: "tail", when, lines: result.lines };
|
|
113
|
+
return maybeBuildHandoffPreview(this.session, when, this.config);
|
|
151
114
|
}
|
|
152
115
|
|
|
153
116
|
private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoff"] | undefined {
|
|
154
|
-
|
|
155
|
-
const lines = this.config.handoffSnapshotLines;
|
|
156
|
-
const maxChars = this.config.handoffSnapshotMaxChars;
|
|
157
|
-
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
158
|
-
|
|
159
|
-
const baseDir = join(getAgentDir(), "cache", "interactive-shell");
|
|
160
|
-
mkdirSync(baseDir, { recursive: true });
|
|
161
|
-
|
|
162
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
163
|
-
const pid = this.session.pid;
|
|
164
|
-
const filename = `snapshot-${timestamp}-pid${pid}.log`;
|
|
165
|
-
const transcriptPath = join(baseDir, filename);
|
|
166
|
-
|
|
167
|
-
const tailResult = this.session.getTailLines({
|
|
168
|
-
lines,
|
|
169
|
-
ansi: this.config.ansiReemit,
|
|
170
|
-
maxChars,
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const header = [
|
|
174
|
-
`# interactive-shell snapshot (${when})`,
|
|
175
|
-
`time: ${new Date().toISOString()}`,
|
|
176
|
-
`command: ${this.bgSession.command}`,
|
|
177
|
-
`pid: ${pid}`,
|
|
178
|
-
`exitCode: ${this.session.exitCode ?? ""}`,
|
|
179
|
-
`signal: ${this.session.signal ?? ""}`,
|
|
180
|
-
`lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
|
|
181
|
-
"",
|
|
182
|
-
].join("\n");
|
|
183
|
-
|
|
184
|
-
writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
|
|
185
|
-
|
|
186
|
-
return { type: "snapshot", when, transcriptPath, linesWritten: tailResult.lines.length };
|
|
117
|
+
return maybeWriteHandoffSnapshot(this.session, when, this.config, { command: this.bgSession.command });
|
|
187
118
|
}
|
|
188
119
|
|
|
189
120
|
private finishAndClose(): void {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { HeadlessDispatchMonitor } from "./headless-monitor.js";
|
|
2
|
+
|
|
3
|
+
/** Centralizes overlay, monitor, widget, and completion-suppression state for the extension runtime. */
|
|
4
|
+
export class InteractiveShellCoordinator {
|
|
5
|
+
private overlayOpen = false;
|
|
6
|
+
private headlessMonitors = new Map<string, HeadlessDispatchMonitor>();
|
|
7
|
+
private bgWidgetCleanup: (() => void) | null = null;
|
|
8
|
+
private agentHandledCompletion = new Set<string>();
|
|
9
|
+
|
|
10
|
+
isOverlayOpen(): boolean {
|
|
11
|
+
return this.overlayOpen;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beginOverlay(): boolean {
|
|
15
|
+
if (this.overlayOpen) return false;
|
|
16
|
+
this.overlayOpen = true;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
endOverlay(): void {
|
|
21
|
+
this.overlayOpen = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
markAgentHandledCompletion(sessionId: string): void {
|
|
25
|
+
this.agentHandledCompletion.add(sessionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
consumeAgentHandledCompletion(sessionId: string): boolean {
|
|
29
|
+
const had = this.agentHandledCompletion.has(sessionId);
|
|
30
|
+
this.agentHandledCompletion.delete(sessionId);
|
|
31
|
+
return had;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setMonitor(id: string, monitor: HeadlessDispatchMonitor): void {
|
|
35
|
+
this.headlessMonitors.set(id, monitor);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getMonitor(id: string): HeadlessDispatchMonitor | undefined {
|
|
39
|
+
return this.headlessMonitors.get(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
deleteMonitor(id: string): void {
|
|
43
|
+
this.headlessMonitors.delete(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disposeMonitor(id: string): void {
|
|
47
|
+
const monitor = this.headlessMonitors.get(id);
|
|
48
|
+
if (!monitor) return;
|
|
49
|
+
monitor.dispose();
|
|
50
|
+
this.headlessMonitors.delete(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
disposeAllMonitors(): void {
|
|
54
|
+
for (const monitor of this.headlessMonitors.values()) {
|
|
55
|
+
monitor.dispose();
|
|
56
|
+
}
|
|
57
|
+
this.headlessMonitors.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
replaceBackgroundWidgetCleanup(cleanup: (() => void) | null): void {
|
|
61
|
+
this.bgWidgetCleanup?.();
|
|
62
|
+
this.bgWidgetCleanup = cleanup;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
clearBackgroundWidget(): void {
|
|
66
|
+
this.bgWidgetCleanup?.();
|
|
67
|
+
this.bgWidgetCleanup = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/scripts/install.js
CHANGED
|
@@ -101,7 +101,11 @@ function main() {
|
|
|
101
101
|
log("Restart pi to load the extension.");
|
|
102
102
|
log("");
|
|
103
103
|
log("Usage:");
|
|
104
|
-
log(' interactive_shell({ command: \'pi "Fix all bugs"\', mode: "
|
|
104
|
+
log(' interactive_shell({ command: \'pi "Fix all bugs"\', mode: "dispatch" })');
|
|
105
|
+
log("");
|
|
106
|
+
log("Bundled example prompts live under extensions/interactive-shell/examples/prompts/");
|
|
107
|
+
log("Bundled example skills live under extensions/interactive-shell/examples/skills/");
|
|
108
|
+
log("Copy them into your prompts/skills directories if you want local slash commands or skill copies.");
|
|
105
109
|
log("");
|
|
106
110
|
}
|
|
107
111
|
|
package/session-manager.ts
CHANGED
|
@@ -191,35 +191,45 @@ export class ShellSessionManager {
|
|
|
191
191
|
add(command: string, session: PtyTerminalSession, name?: string, reason?: string, options?: { id?: string; noAutoCleanup?: boolean; startedAt?: Date }): string {
|
|
192
192
|
const id = options?.id ?? generateSessionId(name);
|
|
193
193
|
if (options?.id) usedIds.add(id);
|
|
194
|
-
|
|
194
|
+
const entry: BackgroundSession = {
|
|
195
195
|
id,
|
|
196
196
|
name: name || deriveSessionName(command),
|
|
197
197
|
command,
|
|
198
198
|
reason,
|
|
199
199
|
session,
|
|
200
200
|
startedAt: options?.startedAt ?? new Date(),
|
|
201
|
-
}
|
|
201
|
+
};
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
this.storeBackgroundEntry(entry, options?.noAutoCleanup === true);
|
|
204
|
+
return id;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
restore(entry: BackgroundSession, options?: { noAutoCleanup?: boolean }): void {
|
|
208
|
+
usedIds.add(entry.id);
|
|
209
|
+
this.storeBackgroundEntry(entry, options?.noAutoCleanup === true);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private storeBackgroundEntry(entry: BackgroundSession, noAutoCleanup: boolean): void {
|
|
213
|
+
this.sessions.set(entry.id, entry);
|
|
214
|
+
entry.session.setEventHandlers({});
|
|
204
215
|
|
|
205
|
-
if (!
|
|
216
|
+
if (!noAutoCleanup) {
|
|
206
217
|
const checkExit = setInterval(() => {
|
|
207
|
-
if (session.exited) {
|
|
218
|
+
if (entry.session.exited) {
|
|
208
219
|
clearInterval(checkExit);
|
|
209
|
-
this.exitWatchers.delete(id);
|
|
220
|
+
this.exitWatchers.delete(entry.id);
|
|
210
221
|
this.notifyChange();
|
|
211
222
|
const cleanupTimer = setTimeout(() => {
|
|
212
|
-
this.cleanupTimers.delete(id);
|
|
213
|
-
this.remove(id);
|
|
223
|
+
this.cleanupTimers.delete(entry.id);
|
|
224
|
+
this.remove(entry.id);
|
|
214
225
|
}, 30000);
|
|
215
|
-
this.cleanupTimers.set(id, cleanupTimer);
|
|
226
|
+
this.cleanupTimers.set(entry.id, cleanupTimer);
|
|
216
227
|
}
|
|
217
228
|
}, 1000);
|
|
218
|
-
this.exitWatchers.set(id, checkExit);
|
|
229
|
+
this.exitWatchers.set(entry.id, checkExit);
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
this.notifyChange();
|
|
222
|
-
return id;
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
take(id: string): BackgroundSession | undefined {
|
package/session-query.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { InteractiveShellConfig } from "./config.js";
|
|
2
|
+
import type { OutputOptions, OutputResult } from "./session-manager.js";
|
|
3
|
+
import type { InteractiveShellResult } from "./types.js";
|
|
4
|
+
import type { PtyTerminalSession } from "./pty-session.js";
|
|
5
|
+
|
|
6
|
+
/** Mutable query bookkeeping kept per active session. */
|
|
7
|
+
export interface SessionQueryState {
|
|
8
|
+
lastQueryTime: number;
|
|
9
|
+
incrementalReadPosition: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_STATUS_OUTPUT = 5 * 1024;
|
|
13
|
+
export const DEFAULT_STATUS_LINES = 20;
|
|
14
|
+
export const MAX_STATUS_OUTPUT = 50 * 1024;
|
|
15
|
+
export const MAX_STATUS_LINES = 200;
|
|
16
|
+
|
|
17
|
+
export function createSessionQueryState(): SessionQueryState {
|
|
18
|
+
return {
|
|
19
|
+
lastQueryTime: 0,
|
|
20
|
+
incrementalReadPosition: 0,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getSessionOutput(
|
|
25
|
+
session: PtyTerminalSession,
|
|
26
|
+
config: InteractiveShellConfig,
|
|
27
|
+
state: SessionQueryState,
|
|
28
|
+
options: OutputOptions | boolean = false,
|
|
29
|
+
completionOutput?: InteractiveShellResult["completionOutput"],
|
|
30
|
+
): OutputResult {
|
|
31
|
+
if (completionOutput) {
|
|
32
|
+
return buildCompletionOutputResult(completionOutput);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
|
|
36
|
+
const requestedLines = clampPositive(opts.lines ?? DEFAULT_STATUS_LINES, MAX_STATUS_LINES);
|
|
37
|
+
const requestedMaxChars = clampPositive(opts.maxChars ?? DEFAULT_STATUS_OUTPUT, MAX_STATUS_OUTPUT);
|
|
38
|
+
const rateLimited = maybeRateLimitQuery(config, state, opts.skipRateLimit ?? false);
|
|
39
|
+
if (rateLimited) return rateLimited;
|
|
40
|
+
|
|
41
|
+
if (opts.incremental) {
|
|
42
|
+
return getIncrementalOutput(session, state, requestedLines, requestedMaxChars);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (opts.drain) {
|
|
46
|
+
return buildTruncatedOutput(session.getRawStream({ sinceLast: true, stripAnsi: true }), requestedMaxChars, true);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (opts.offset !== undefined) {
|
|
50
|
+
return getOffsetOutput(session, opts.offset, requestedLines, requestedMaxChars);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tailResult = session.getTailLines({
|
|
54
|
+
lines: requestedLines,
|
|
55
|
+
ansi: false,
|
|
56
|
+
maxChars: requestedMaxChars,
|
|
57
|
+
});
|
|
58
|
+
const output = tailResult.lines.join("\n");
|
|
59
|
+
return {
|
|
60
|
+
output,
|
|
61
|
+
truncated: tailResult.lines.length < tailResult.totalLinesInBuffer || tailResult.truncatedByChars,
|
|
62
|
+
totalBytes: output.length,
|
|
63
|
+
totalLines: tailResult.totalLinesInBuffer,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maybeRateLimitQuery(
|
|
68
|
+
config: InteractiveShellConfig,
|
|
69
|
+
state: SessionQueryState,
|
|
70
|
+
skipRateLimit: boolean,
|
|
71
|
+
): OutputResult | null {
|
|
72
|
+
if (skipRateLimit) return null;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const minIntervalMs = config.minQueryIntervalSeconds * 1000;
|
|
75
|
+
const elapsed = now - state.lastQueryTime;
|
|
76
|
+
if (state.lastQueryTime > 0 && elapsed < minIntervalMs) {
|
|
77
|
+
return {
|
|
78
|
+
output: "",
|
|
79
|
+
truncated: false,
|
|
80
|
+
totalBytes: 0,
|
|
81
|
+
rateLimited: true,
|
|
82
|
+
waitSeconds: Math.ceil((minIntervalMs - elapsed) / 1000),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
state.lastQueryTime = now;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getIncrementalOutput(
|
|
90
|
+
session: PtyTerminalSession,
|
|
91
|
+
state: SessionQueryState,
|
|
92
|
+
requestedLines: number,
|
|
93
|
+
requestedMaxChars: number,
|
|
94
|
+
): OutputResult {
|
|
95
|
+
const result = session.getLogSlice({
|
|
96
|
+
offset: state.incrementalReadPosition,
|
|
97
|
+
limit: requestedLines,
|
|
98
|
+
stripAnsi: true,
|
|
99
|
+
});
|
|
100
|
+
const output = truncateForMaxChars(result.slice, requestedMaxChars);
|
|
101
|
+
state.incrementalReadPosition += result.sliceLineCount;
|
|
102
|
+
return {
|
|
103
|
+
output: output.value,
|
|
104
|
+
truncated: output.truncated,
|
|
105
|
+
totalBytes: output.value.length,
|
|
106
|
+
totalLines: result.totalLines,
|
|
107
|
+
hasMore: state.incrementalReadPosition < result.totalLines,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getOffsetOutput(
|
|
112
|
+
session: PtyTerminalSession,
|
|
113
|
+
offset: number,
|
|
114
|
+
requestedLines: number,
|
|
115
|
+
requestedMaxChars: number,
|
|
116
|
+
): OutputResult {
|
|
117
|
+
const result = session.getLogSlice({
|
|
118
|
+
offset,
|
|
119
|
+
limit: requestedLines,
|
|
120
|
+
stripAnsi: true,
|
|
121
|
+
});
|
|
122
|
+
const output = truncateForMaxChars(result.slice, requestedMaxChars);
|
|
123
|
+
const hasMore = (offset + result.sliceLineCount) < result.totalLines;
|
|
124
|
+
return {
|
|
125
|
+
output: output.value,
|
|
126
|
+
truncated: output.truncated || hasMore,
|
|
127
|
+
totalBytes: output.value.length,
|
|
128
|
+
totalLines: result.totalLines,
|
|
129
|
+
hasMore,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildCompletionOutputResult(completionOutput: NonNullable<InteractiveShellResult["completionOutput"]>): OutputResult {
|
|
134
|
+
const output = completionOutput.lines.join("\n");
|
|
135
|
+
return {
|
|
136
|
+
output,
|
|
137
|
+
truncated: completionOutput.truncated,
|
|
138
|
+
totalBytes: output.length,
|
|
139
|
+
totalLines: completionOutput.totalLines,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildTruncatedOutput(output: string, requestedMaxChars: number, sliceFromEnd = false): OutputResult {
|
|
144
|
+
const truncated = output.length > requestedMaxChars;
|
|
145
|
+
let value = output;
|
|
146
|
+
if (truncated) {
|
|
147
|
+
value = sliceFromEnd
|
|
148
|
+
? output.slice(-requestedMaxChars)
|
|
149
|
+
: output.slice(0, requestedMaxChars);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
output: value,
|
|
153
|
+
truncated,
|
|
154
|
+
totalBytes: value.length,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function truncateForMaxChars(output: string, requestedMaxChars: number): { value: string; truncated: boolean } {
|
|
159
|
+
if (output.length <= requestedMaxChars) {
|
|
160
|
+
return { value: output, truncated: false };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
value: output.slice(0, requestedMaxChars),
|
|
164
|
+
truncated: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function clampPositive(value: number, max: number): number {
|
|
169
|
+
return Math.max(1, Math.min(max, value));
|
|
170
|
+
}
|
package/spawn-helper.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { chmodSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
let spawnHelperChecked = false;
|
|
7
|
+
|
|
8
|
+
export function ensureSpawnHelperExec(): void {
|
|
9
|
+
if (spawnHelperChecked) return;
|
|
10
|
+
spawnHelperChecked = true;
|
|
11
|
+
if (process.platform !== "darwin") return;
|
|
12
|
+
|
|
13
|
+
let pkgPath: string;
|
|
14
|
+
try {
|
|
15
|
+
pkgPath = require.resolve("node-pty/package.json");
|
|
16
|
+
} catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const base = dirname(pkgPath);
|
|
21
|
+
const targets = [
|
|
22
|
+
join(base, "prebuilds", "darwin-arm64", "spawn-helper"),
|
|
23
|
+
join(base, "prebuilds", "darwin-x64", "spawn-helper"),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
for (const target of targets) {
|
|
27
|
+
try {
|
|
28
|
+
const stats = statSync(target);
|
|
29
|
+
const mode = stats.mode | 0o111;
|
|
30
|
+
if ((stats.mode & 0o111) !== 0o111) {
|
|
31
|
+
chmodSync(target, mode);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/types.ts
CHANGED
|
@@ -48,11 +48,14 @@ export interface HandsFreeUpdate {
|
|
|
48
48
|
budgetExhausted?: boolean;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Options for starting or reattaching an interactive shell session. */
|
|
51
52
|
export interface InteractiveShellOptions {
|
|
52
53
|
command: string;
|
|
53
54
|
cwd?: string;
|
|
54
55
|
name?: string;
|
|
55
56
|
reason?: string;
|
|
57
|
+
/** Original session start time in ms since epoch, preserved across background/reattach transitions. */
|
|
58
|
+
startedAt?: number;
|
|
56
59
|
handoffPreviewEnabled?: boolean;
|
|
57
60
|
handoffPreviewLines?: number;
|
|
58
61
|
handoffPreviewMaxChars?: number;
|