pi-interactive-shell 0.8.2 → 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/pty-log.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+
3
+ export const MAX_RAW_OUTPUT_SIZE = 1024 * 1024;
4
+
5
+ export function trimRawOutput(rawOutput: string, lastStreamPosition: number): { rawOutput: string; lastStreamPosition: number } {
6
+ if (rawOutput.length <= MAX_RAW_OUTPUT_SIZE) {
7
+ return { rawOutput, lastStreamPosition };
8
+ }
9
+ const keepSize = Math.floor(MAX_RAW_OUTPUT_SIZE / 2);
10
+ const trimAmount = rawOutput.length - keepSize;
11
+ return {
12
+ rawOutput: rawOutput.substring(trimAmount),
13
+ lastStreamPosition: Math.max(0, lastStreamPosition - trimAmount),
14
+ };
15
+ }
16
+
17
+ export function sliceLogOutput(text: string, options: { offset?: number; limit?: number; stripAnsi?: boolean } = {}): {
18
+ slice: string;
19
+ totalLines: number;
20
+ totalChars: number;
21
+ sliceLineCount: number;
22
+ } {
23
+ let source = text;
24
+ if (options.stripAnsi !== false && source) {
25
+ source = stripVTControlCharacters(source);
26
+ }
27
+ if (!source) {
28
+ return { slice: "", totalLines: 0, totalChars: 0, sliceLineCount: 0 };
29
+ }
30
+
31
+ const normalized = source.replace(/\r\n/g, "\n");
32
+ const lines = normalized.split("\n");
33
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
34
+ lines.pop();
35
+ }
36
+
37
+ const totalLines = lines.length;
38
+ const totalChars = source.length;
39
+ let start: number;
40
+ if (typeof options.offset === "number" && Number.isFinite(options.offset)) {
41
+ start = Math.max(0, Math.floor(options.offset));
42
+ } else if (options.limit !== undefined) {
43
+ const tailCount = Math.max(0, Math.floor(options.limit));
44
+ start = Math.max(totalLines - tailCount, 0);
45
+ } else {
46
+ start = 0;
47
+ }
48
+
49
+ const end = typeof options.limit === "number" && Number.isFinite(options.limit)
50
+ ? start + Math.max(0, Math.floor(options.limit))
51
+ : undefined;
52
+ const selectedLines = lines.slice(start, end);
53
+ return {
54
+ slice: selectedLines.join("\n"),
55
+ totalLines,
56
+ totalChars,
57
+ sliceLineCount: selectedLines.length,
58
+ };
59
+ }
@@ -0,0 +1,33 @@
1
+ // DSR (Device Status Report) - cursor position query: ESC[6n or ESC[?6n
2
+ const DSR_PATTERN = /\x1b\[\??6n/g;
3
+
4
+ /** Result of splitting PTY output around device-status-report cursor queries. */
5
+ export interface DsrSplit {
6
+ segments: Array<{ text: string; dsrAfter: boolean }>;
7
+ hasDsr: boolean;
8
+ }
9
+
10
+ export function splitAroundDsr(input: string): DsrSplit {
11
+ const segments: Array<{ text: string; dsrAfter: boolean }> = [];
12
+ let lastIndex = 0;
13
+ let hasDsr = false;
14
+ const regex = new RegExp(DSR_PATTERN.source, "g");
15
+ let match: RegExpExecArray | null;
16
+ while ((match = regex.exec(input)) !== null) {
17
+ hasDsr = true;
18
+ if (match.index > lastIndex) {
19
+ segments.push({ text: input.slice(lastIndex, match.index), dsrAfter: true });
20
+ } else {
21
+ segments.push({ text: "", dsrAfter: true });
22
+ }
23
+ lastIndex = match.index + match[0].length;
24
+ }
25
+ if (lastIndex < input.length) {
26
+ segments.push({ text: input.slice(lastIndex), dsrAfter: false });
27
+ }
28
+ return { segments, hasDsr };
29
+ }
30
+
31
+ export function buildCursorPositionResponse(row = 1, col = 1): string {
32
+ return `\x1b[${row};${col}R`;
33
+ }
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
- if (this.rawOutput.length > MAX_RAW_OUTPUT_SIZE) {
238
- const keepSize = Math.floor(MAX_RAW_OUTPUT_SIZE / 2);
239
- const trimAmount = this.rawOutput.length - keepSize;
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
- for (const listener of this.additionalDataListeners) {
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
- for (const listener of this.additionalExitListeners) {
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
- let text = this.rawOutput;
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 {
@@ -1,6 +1,3 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
1
  import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
5
2
  import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
3
  import type { Theme } from "@mariozechner/pi-coding-agent";
@@ -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
- const result = this.session.getTailLines({
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
- const maxLines = this.config.transferLines;
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
- if (!this.config.handoffPreviewEnabled) return undefined;
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
- if (!this.config.handoffSnapshotEnabled) return undefined;
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(homedir(), ".pi", "agent", "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 {
@@ -358,6 +289,7 @@ export class ReattachOverlay implements Component, Focusable {
358
289
  }
359
290
 
360
291
  render(width: number): string[] {
292
+ width = Math.max(4, width);
361
293
  const th = this.theme;
362
294
  const border = (s: string) => th.fg("border", s);
363
295
  const accent = (s: string) => th.fg("accent", s);
@@ -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
+ }
@@ -9,8 +9,19 @@ import { fileURLToPath } from "node:url";
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  const packageRoot = join(__dirname, "..");
11
11
 
12
- const EXTENSION_DIR = join(homedir(), ".pi", "agent", "extensions", "interactive-shell");
13
- const SKILL_DIR = join(homedir(), ".pi", "agent", "skills", "interactive-shell");
12
+ function getAgentDir() {
13
+ const envDir = process.env.PI_CODING_AGENT_DIR;
14
+ if (envDir) {
15
+ if (envDir === "~") return homedir();
16
+ if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
17
+ return envDir;
18
+ }
19
+ return join(homedir(), ".pi", "agent");
20
+ }
21
+
22
+ const agentDir = getAgentDir();
23
+ const EXTENSION_DIR = join(agentDir, "extensions", "interactive-shell");
24
+ const SKILL_DIR = join(agentDir, "skills", "interactive-shell");
14
25
 
15
26
  function log(msg) {
16
27
  console.log(`[pi-interactive-shell] ${msg}`);
@@ -90,7 +101,11 @@ function main() {
90
101
  log("Restart pi to load the extension.");
91
102
  log("");
92
103
  log("Usage:");
93
- log(' interactive_shell({ command: \'pi "Fix all bugs"\', mode: "hands-free" })');
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.");
94
109
  log("");
95
110
  }
96
111
 
@@ -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
- this.sessions.set(id, {
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
- session.setEventHandlers({});
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 (!options?.noAutoCleanup) {
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 {