pi-interactive-shell 0.9.0 → 0.10.1
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 +32 -0
- package/README.md +21 -13
- package/SKILL.md +3 -3
- 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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { InteractiveShellResult, HandsFreeUpdate } from "./types.js";
|
|
2
|
+
import type { HeadlessCompletionInfo } from "./headless-monitor.js";
|
|
3
|
+
import { formatDurationMs } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const BRIEF_TAIL_LINES = 5;
|
|
6
|
+
|
|
7
|
+
export function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
|
|
8
|
+
const parts = [buildDispatchStatusLine(sessionId, info, duration)];
|
|
9
|
+
if (info.completionOutput && info.completionOutput.totalLines > 0) {
|
|
10
|
+
parts.push(` ${info.completionOutput.totalLines} lines of output.`);
|
|
11
|
+
}
|
|
12
|
+
appendTailBlock(parts, info.completionOutput?.lines, BRIEF_TAIL_LINES);
|
|
13
|
+
parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
|
|
14
|
+
return parts.join("");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
|
|
18
|
+
const parts = [buildResultStatusLine(sessionId, result)];
|
|
19
|
+
if (result.completionOutput && result.completionOutput.lines.length > 0) {
|
|
20
|
+
const truncNote = result.completionOutput.truncated
|
|
21
|
+
? ` (truncated from ${result.completionOutput.totalLines} total lines)`
|
|
22
|
+
: "";
|
|
23
|
+
parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
|
|
24
|
+
}
|
|
25
|
+
return parts.join("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildHandsFreeUpdateMessage(update: HandsFreeUpdate): { content: string; details: HandsFreeUpdate } | null {
|
|
29
|
+
if (update.status === "running") return null;
|
|
30
|
+
|
|
31
|
+
const tail = update.tail.length > 0 ? `\n\n${update.tail.join("\n")}` : "";
|
|
32
|
+
let statusLine: string;
|
|
33
|
+
switch (update.status) {
|
|
34
|
+
case "exited":
|
|
35
|
+
statusLine = `Session ${update.sessionId} exited (${formatDurationMs(update.runtime)})`;
|
|
36
|
+
break;
|
|
37
|
+
case "killed":
|
|
38
|
+
statusLine = `Session ${update.sessionId} killed (${formatDurationMs(update.runtime)})`;
|
|
39
|
+
break;
|
|
40
|
+
case "user-takeover":
|
|
41
|
+
statusLine = `Session ${update.sessionId}: user took over (${formatDurationMs(update.runtime)})`;
|
|
42
|
+
break;
|
|
43
|
+
default:
|
|
44
|
+
statusLine = `Session ${update.sessionId} update (${formatDurationMs(update.runtime)})`;
|
|
45
|
+
}
|
|
46
|
+
return { content: statusLine + tail, details: update };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function summarizeInteractiveResult(command: string, result: InteractiveShellResult, timeout?: number, reason?: string): string {
|
|
50
|
+
let summary = buildInteractiveSummary(result, timeout);
|
|
51
|
+
|
|
52
|
+
if (result.userTookOver) {
|
|
53
|
+
summary += "\n\nNote: User took over control during hands-free mode.";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
|
|
57
|
+
summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const warning = buildIdlePromptWarning(command, reason);
|
|
61
|
+
if (warning) {
|
|
62
|
+
summary += `\n\n${warning}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return summary;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
|
|
69
|
+
if (!reason) return null;
|
|
70
|
+
|
|
71
|
+
const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
|
|
72
|
+
if (!tasky.test(reason)) return null;
|
|
73
|
+
|
|
74
|
+
const trimmed = command.trim();
|
|
75
|
+
const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
|
|
76
|
+
const bin = binaries.find((candidate) => trimmed === candidate || trimmed.startsWith(`${candidate} `));
|
|
77
|
+
if (!bin) return null;
|
|
78
|
+
|
|
79
|
+
const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
|
|
80
|
+
const hasQuotedPrompt = /["']/.test(rest);
|
|
81
|
+
const hasKnownPromptFlag =
|
|
82
|
+
/\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
|
|
83
|
+
(bin === "pi" && /\b-p\b/.test(rest)) ||
|
|
84
|
+
(bin === "codex" && /\bexec\b/.test(rest));
|
|
85
|
+
|
|
86
|
+
if (hasQuotedPrompt || hasKnownPromptFlag) return null;
|
|
87
|
+
if (!looksLikeIdleCommand(rest)) return null;
|
|
88
|
+
|
|
89
|
+
const examplePrompt = reason.replace(/\s+/g, " ").trim();
|
|
90
|
+
const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
|
|
91
|
+
return `Note: \`reason\` is UI-only. This command likely started the agent idle. If you intended an initial prompt, embed it in \`command\`, e.g. \`${bin} "${clipped}"\`.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildDispatchStatusLine(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
|
|
95
|
+
if (info.timedOut) return `Session ${sessionId} timed out (${duration}).`;
|
|
96
|
+
if (info.cancelled) return `Session ${sessionId} completed (${duration}).`;
|
|
97
|
+
if (info.exitCode === 0) return `Session ${sessionId} completed successfully (${duration}).`;
|
|
98
|
+
return `Session ${sessionId} exited with code ${info.exitCode} (${duration}).`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildResultStatusLine(sessionId: string, result: InteractiveShellResult): string {
|
|
102
|
+
if (result.timedOut) return `Session ${sessionId} timed out.`;
|
|
103
|
+
if (result.cancelled) return `Session ${sessionId} was killed.`;
|
|
104
|
+
if (result.exitCode === 0) return `Session ${sessionId} completed successfully.`;
|
|
105
|
+
return `Session ${sessionId} exited with code ${result.exitCode}.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildInteractiveSummary(result: InteractiveShellResult, timeout?: number): string {
|
|
109
|
+
if (result.transferred) {
|
|
110
|
+
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
|
|
111
|
+
return `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
|
|
112
|
+
}
|
|
113
|
+
if (result.backgrounded) {
|
|
114
|
+
return `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
|
|
115
|
+
}
|
|
116
|
+
if (result.cancelled) return "User killed the interactive session";
|
|
117
|
+
if (result.timedOut) return `Session killed after timeout (${timeout ?? "?"}ms)`;
|
|
118
|
+
const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
|
|
119
|
+
return `Session ended ${status}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendTailBlock(parts: string[], lines: string[] | undefined, tailLines: number): void {
|
|
123
|
+
if (!lines || lines.length === 0) return;
|
|
124
|
+
let end = lines.length;
|
|
125
|
+
while (end > 0 && lines[end - 1].trim() === "") end--;
|
|
126
|
+
const tail = lines.slice(Math.max(0, end - tailLines), end);
|
|
127
|
+
if (tail.length > 0) {
|
|
128
|
+
parts.push(`\n\n${tail.join("\n")}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function looksLikeIdleCommand(rest: string): boolean {
|
|
133
|
+
return rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest);
|
|
134
|
+
}
|
package/overlay-component.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
1
|
import { stripVTControlCharacters } from "node:util";
|
|
4
2
|
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
|
|
5
3
|
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
6
4
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
-
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
8
5
|
import { PtyTerminalSession } from "./pty-session.js";
|
|
9
6
|
import { sessionManager, generateSessionId } from "./session-manager.js";
|
|
10
7
|
import type { InteractiveShellConfig } from "./config.js";
|
|
@@ -19,6 +16,8 @@ import {
|
|
|
19
16
|
FOOTER_LINES_DIALOG,
|
|
20
17
|
formatDuration,
|
|
21
18
|
} from "./types.js";
|
|
19
|
+
import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
|
|
20
|
+
import { createSessionQueryState, getSessionOutput } from "./session-query.js";
|
|
22
21
|
|
|
23
22
|
export class InteractiveShellOverlay implements Component, Focusable {
|
|
24
23
|
focused = false;
|
|
@@ -40,7 +39,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
40
39
|
private userTookOver = false;
|
|
41
40
|
private handsFreeInterval: ReturnType<typeof setInterval> | null = null;
|
|
42
41
|
private handsFreeInitialTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
43
|
-
private startTime
|
|
42
|
+
private startTime: number;
|
|
44
43
|
private sessionId: string | null = null;
|
|
45
44
|
private sessionUnregistered = false;
|
|
46
45
|
// Timeout
|
|
@@ -57,10 +56,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
57
56
|
private hasUnsentData = false;
|
|
58
57
|
// Non-blocking mode: track status for agent queries
|
|
59
58
|
private completionResult: InteractiveShellResult | undefined;
|
|
60
|
-
|
|
61
|
-
private lastQueryTime = 0;
|
|
62
|
-
// Incremental read position (for incremental: true queries)
|
|
63
|
-
private incrementalReadPosition = 0;
|
|
59
|
+
private queryState = createSessionQueryState();
|
|
64
60
|
// Completion callbacks for waiters
|
|
65
61
|
private completeCallbacks: Array<() => void> = [];
|
|
66
62
|
// Simple render throttle to reduce flicker
|
|
@@ -78,6 +74,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
78
74
|
this.options = options;
|
|
79
75
|
this.config = config;
|
|
80
76
|
this.done = done;
|
|
77
|
+
this.startTime = options.startedAt ?? Date.now();
|
|
81
78
|
|
|
82
79
|
const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
|
|
83
80
|
const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
|
|
@@ -209,136 +206,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
209
206
|
|
|
210
207
|
// Public methods for non-blocking mode (agent queries)
|
|
211
208
|
|
|
212
|
-
// Default output limits per status query
|
|
213
|
-
private static readonly DEFAULT_STATUS_OUTPUT = 5 * 1024; // 5KB
|
|
214
|
-
private static readonly DEFAULT_STATUS_LINES = 20;
|
|
215
|
-
private static readonly MAX_STATUS_OUTPUT = 50 * 1024; // 50KB max
|
|
216
|
-
private static readonly MAX_STATUS_LINES = 200; // 200 lines max
|
|
217
|
-
|
|
218
209
|
/** Get rendered terminal output (last N lines, truncated if too large) */
|
|
219
210
|
getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number; offset?: number; drain?: boolean; incremental?: boolean } | boolean = false): { output: string; truncated: boolean; totalBytes: number; totalLines?: number; hasMore?: boolean; rateLimited?: boolean; waitSeconds?: number } {
|
|
220
|
-
|
|
221
|
-
if (this.completionResult?.completionOutput) {
|
|
222
|
-
const lines = this.completionResult.completionOutput.lines;
|
|
223
|
-
const output = lines.join("\n");
|
|
224
|
-
return {
|
|
225
|
-
output,
|
|
226
|
-
truncated: this.completionResult.completionOutput.truncated,
|
|
227
|
-
totalBytes: output.length,
|
|
228
|
-
totalLines: this.completionResult.completionOutput.totalLines,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
return { output: "", truncated: false, totalBytes: 0 };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Handle legacy boolean parameter
|
|
235
|
-
const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
|
|
236
|
-
const skipRateLimit = opts.skipRateLimit ?? false;
|
|
237
|
-
// Clamp lines and maxChars to valid ranges (1 to MAX)
|
|
238
|
-
const requestedLines = Math.max(1, Math.min(
|
|
239
|
-
opts.lines ?? InteractiveShellOverlay.DEFAULT_STATUS_LINES,
|
|
240
|
-
InteractiveShellOverlay.MAX_STATUS_LINES
|
|
241
|
-
));
|
|
242
|
-
const requestedMaxChars = Math.max(1, Math.min(
|
|
243
|
-
opts.maxChars ?? InteractiveShellOverlay.DEFAULT_STATUS_OUTPUT,
|
|
244
|
-
InteractiveShellOverlay.MAX_STATUS_OUTPUT
|
|
245
|
-
));
|
|
246
|
-
|
|
247
|
-
// Check rate limiting (unless skipped, e.g., for completed sessions)
|
|
248
|
-
if (!skipRateLimit) {
|
|
249
|
-
const now = Date.now();
|
|
250
|
-
const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
|
|
251
|
-
const elapsed = now - this.lastQueryTime;
|
|
252
|
-
|
|
253
|
-
if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
|
|
254
|
-
const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
|
|
255
|
-
return {
|
|
256
|
-
output: "",
|
|
257
|
-
truncated: false,
|
|
258
|
-
totalBytes: 0,
|
|
259
|
-
rateLimited: true,
|
|
260
|
-
waitSeconds,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Update last query time
|
|
265
|
-
this.lastQueryTime = now;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Incremental mode: return next N lines agent hasn't seen yet
|
|
269
|
-
// Server tracks position - agent just keeps calling with incremental: true
|
|
270
|
-
if (opts.incremental) {
|
|
271
|
-
const result = this.session.getLogSlice({
|
|
272
|
-
offset: this.incrementalReadPosition,
|
|
273
|
-
limit: requestedLines,
|
|
274
|
-
stripAnsi: true,
|
|
275
|
-
});
|
|
276
|
-
// Use sliceLineCount directly - handles empty lines correctly
|
|
277
|
-
// (counting newlines in slice fails for empty lines like "")
|
|
278
|
-
const linesFromSlice = result.sliceLineCount;
|
|
279
|
-
// Apply maxChars limit (may truncate mid-line, but we still advance past it)
|
|
280
|
-
const truncatedByChars = result.slice.length > requestedMaxChars;
|
|
281
|
-
const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
|
|
282
|
-
// Update position for next incremental read
|
|
283
|
-
this.incrementalReadPosition += linesFromSlice;
|
|
284
|
-
const hasMore = this.incrementalReadPosition < result.totalLines;
|
|
285
|
-
return {
|
|
286
|
-
output,
|
|
287
|
-
truncated: truncatedByChars,
|
|
288
|
-
totalBytes: output.length,
|
|
289
|
-
totalLines: result.totalLines,
|
|
290
|
-
hasMore,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Drain mode: return only NEW output since last query (raw stream, not lines)
|
|
295
|
-
// This is more token-efficient than re-reading the tail each time
|
|
296
|
-
if (opts.drain) {
|
|
297
|
-
const newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true });
|
|
298
|
-
// Truncate if exceeds maxChars
|
|
299
|
-
const truncated = newOutput.length > requestedMaxChars;
|
|
300
|
-
const output = truncated ? newOutput.slice(-requestedMaxChars) : newOutput;
|
|
301
|
-
return {
|
|
302
|
-
output,
|
|
303
|
-
truncated,
|
|
304
|
-
totalBytes: output.length,
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Offset mode: use getLogSlice for pagination through full output
|
|
309
|
-
if (opts.offset !== undefined) {
|
|
310
|
-
const result = this.session.getLogSlice({
|
|
311
|
-
offset: opts.offset,
|
|
312
|
-
limit: requestedLines,
|
|
313
|
-
stripAnsi: true,
|
|
314
|
-
});
|
|
315
|
-
// Apply maxChars limit
|
|
316
|
-
const truncatedByChars = result.slice.length > requestedMaxChars;
|
|
317
|
-
const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
|
|
318
|
-
// Calculate hasMore based on whether there are more lines after this slice
|
|
319
|
-
const hasMore = (opts.offset + result.sliceLineCount) < result.totalLines;
|
|
320
|
-
return {
|
|
321
|
-
output,
|
|
322
|
-
truncated: truncatedByChars || result.sliceLineCount >= requestedLines,
|
|
323
|
-
totalBytes: output.length,
|
|
324
|
-
totalLines: result.totalLines,
|
|
325
|
-
hasMore,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Default: Use rendered terminal output (tail)
|
|
330
|
-
// This gives clean, readable content without TUI animation garbage
|
|
331
|
-
const tailResult = this.session.getTailLines({
|
|
332
|
-
lines: requestedLines,
|
|
333
|
-
ansi: false,
|
|
334
|
-
maxChars: requestedMaxChars,
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
const output = tailResult.lines.join("\n");
|
|
338
|
-
const totalBytes = output.length;
|
|
339
|
-
const truncated = tailResult.lines.length >= requestedLines || tailResult.truncatedByChars;
|
|
340
|
-
|
|
341
|
-
return { output, truncated, totalBytes, totalLines: tailResult.totalLinesInBuffer };
|
|
211
|
+
return getSessionOutput(this.session, this.config, this.queryState, options, this.completionResult?.completionOutput);
|
|
342
212
|
}
|
|
343
213
|
|
|
344
214
|
/** Get current session status */
|
|
@@ -662,92 +532,23 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
662
532
|
|
|
663
533
|
/** Capture output for dispatch completion notifications */
|
|
664
534
|
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
|
|
665
|
-
|
|
666
|
-
lines: this.config.completionNotifyLines,
|
|
667
|
-
ansi: false,
|
|
668
|
-
maxChars: this.config.completionNotifyMaxChars,
|
|
669
|
-
});
|
|
670
|
-
return {
|
|
671
|
-
lines: result.lines,
|
|
672
|
-
totalLines: result.totalLinesInBuffer,
|
|
673
|
-
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
|
|
674
|
-
};
|
|
535
|
+
return captureCompletionOutput(this.session, this.config);
|
|
675
536
|
}
|
|
676
537
|
|
|
677
538
|
/** Capture output for transfer action (Ctrl+T or dialog) */
|
|
678
539
|
private captureTransferOutput(): InteractiveShellResult["transferred"] {
|
|
679
|
-
|
|
680
|
-
const maxChars = this.config.transferMaxChars;
|
|
681
|
-
|
|
682
|
-
const result = this.session.getTailLines({
|
|
683
|
-
lines: maxLines,
|
|
684
|
-
ansi: false,
|
|
685
|
-
maxChars,
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
|
|
689
|
-
|
|
690
|
-
return {
|
|
691
|
-
lines: result.lines,
|
|
692
|
-
totalLines: result.totalLinesInBuffer,
|
|
693
|
-
truncated,
|
|
694
|
-
};
|
|
540
|
+
return captureTransferOutput(this.session, this.config);
|
|
695
541
|
}
|
|
696
542
|
|
|
697
543
|
private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
|
|
698
|
-
|
|
699
|
-
if (!enabled) return undefined;
|
|
700
|
-
|
|
701
|
-
const lines = this.options.handoffPreviewLines ?? this.config.handoffPreviewLines;
|
|
702
|
-
const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
|
|
703
|
-
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
704
|
-
|
|
705
|
-
const result = this.session.getTailLines({
|
|
706
|
-
lines,
|
|
707
|
-
ansi: false,
|
|
708
|
-
maxChars,
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
return { type: "tail", when, lines: result.lines };
|
|
544
|
+
return maybeBuildHandoffPreview(this.session, when, this.config, this.options);
|
|
712
545
|
}
|
|
713
546
|
|
|
714
547
|
private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const maxChars = this.options.handoffSnapshotMaxChars ?? this.config.handoffSnapshotMaxChars;
|
|
720
|
-
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
721
|
-
|
|
722
|
-
const baseDir = join(getAgentDir(), "cache", "interactive-shell");
|
|
723
|
-
mkdirSync(baseDir, { recursive: true });
|
|
724
|
-
|
|
725
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
726
|
-
const pid = this.session.pid;
|
|
727
|
-
const filename = `snapshot-${timestamp}-pid${pid}.log`;
|
|
728
|
-
const transcriptPath = join(baseDir, filename);
|
|
729
|
-
|
|
730
|
-
const tailResult = this.session.getTailLines({
|
|
731
|
-
lines,
|
|
732
|
-
ansi: this.config.ansiReemit,
|
|
733
|
-
maxChars,
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
const header = [
|
|
737
|
-
`# interactive-shell snapshot (${when})`,
|
|
738
|
-
`time: ${new Date().toISOString()}`,
|
|
739
|
-
`command: ${this.options.command}`,
|
|
740
|
-
`cwd: ${this.options.cwd ?? ""}`,
|
|
741
|
-
`pid: ${pid}`,
|
|
742
|
-
`exitCode: ${this.session.exitCode ?? ""}`,
|
|
743
|
-
`signal: ${this.session.signal ?? ""}`,
|
|
744
|
-
`lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
|
|
745
|
-
"",
|
|
746
|
-
].join("\n");
|
|
747
|
-
|
|
748
|
-
writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
|
|
749
|
-
|
|
750
|
-
return { type: "snapshot", when, transcriptPath, linesWritten: tailResult.lines.length };
|
|
548
|
+
return maybeWriteHandoffSnapshot(this.session, when, this.config, {
|
|
549
|
+
command: this.options.command,
|
|
550
|
+
cwd: this.options.cwd,
|
|
551
|
+
}, this.options);
|
|
751
552
|
}
|
|
752
553
|
|
|
753
554
|
private finishWithExit(): void {
|
|
@@ -798,7 +599,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
798
599
|
const handoffPreview = this.maybeBuildHandoffPreview("detach");
|
|
799
600
|
const handoff = this.maybeWriteHandoffSnapshot("detach");
|
|
800
601
|
const addOptions = this.sessionId
|
|
801
|
-
? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch" }
|
|
602
|
+
? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch", startedAt: new Date(this.startTime) }
|
|
802
603
|
: undefined;
|
|
803
604
|
const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions);
|
|
804
605
|
const result: InteractiveShellResult = {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-interactive-shell",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run AI coding agents
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "Run AI coding agents in pi TUI overlays with interactive, hands-free, and dispatch supervision",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pi-interactive-shell": "./scripts/install.js"
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"tool-schema.ts",
|
|
18
18
|
"headless-monitor.ts",
|
|
19
19
|
"types.ts",
|
|
20
|
+
"background-widget.ts",
|
|
21
|
+
"handoff-utils.ts",
|
|
22
|
+
"notification-utils.ts",
|
|
23
|
+
"pty-log.ts",
|
|
24
|
+
"pty-protocol.ts",
|
|
25
|
+
"runtime-coordinator.ts",
|
|
26
|
+
"session-query.ts",
|
|
27
|
+
"spawn-helper.ts",
|
|
20
28
|
"scripts/",
|
|
21
29
|
"examples/",
|
|
22
30
|
"banner.png",
|
|
@@ -28,15 +36,24 @@
|
|
|
28
36
|
"extensions": [
|
|
29
37
|
"./index.ts"
|
|
30
38
|
],
|
|
39
|
+
"skills": [
|
|
40
|
+
"./SKILL.md",
|
|
41
|
+
"./examples/skills"
|
|
42
|
+
],
|
|
43
|
+
"prompts": [
|
|
44
|
+
"./examples/prompts"
|
|
45
|
+
],
|
|
31
46
|
"video": "https://github.com/nicobailon/pi-interactive-shell/raw/refs/heads/main/pi-interactive-shell-extension.mp4"
|
|
32
47
|
},
|
|
33
48
|
"dependencies": {
|
|
34
|
-
"
|
|
49
|
+
"@sinclair/typebox": "^0.34.40",
|
|
50
|
+
"@xterm/addon-serialize": "^0.13.0",
|
|
35
51
|
"@xterm/headless": "^5.5.0",
|
|
36
|
-
"
|
|
52
|
+
"node-pty": "^1.1.0"
|
|
37
53
|
},
|
|
38
54
|
"scripts": {
|
|
39
|
-
"postinstall": "node ./scripts/fix-spawn-helper.cjs"
|
|
55
|
+
"postinstall": "node ./scripts/fix-spawn-helper.cjs",
|
|
56
|
+
"test": "vitest run"
|
|
40
57
|
},
|
|
41
58
|
"keywords": [
|
|
42
59
|
"pi-package",
|
|
@@ -61,5 +78,8 @@
|
|
|
61
78
|
"bugs": {
|
|
62
79
|
"url": "https://github.com/nicobailon/pi-interactive-shell/issues"
|
|
63
80
|
},
|
|
64
|
-
"homepage": "https://github.com/nicobailon/pi-interactive-shell#readme"
|
|
81
|
+
"homepage": "https://github.com/nicobailon/pi-interactive-shell#readme",
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"vitest": "^3.2.4"
|
|
84
|
+
}
|
|
65
85
|
}
|
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
|
+
}
|
package/pty-protocol.ts
ADDED
|
@@ -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
|
+
}
|