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/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,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
- 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(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
+ }
@@ -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: "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.");
105
109
  log("");
106
110
  }
107
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 {
@@ -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
+ }
@@ -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;