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/CHANGELOG.md +47 -1
- package/README.md +22 -14
- package/SKILL.md +4 -2
- package/background-widget.ts +76 -0
- package/config.ts +4 -4
- package/examples/prompts/codex-implement-plan.md +18 -7
- package/examples/prompts/codex-review-impl.md +16 -5
- package/examples/prompts/codex-review-plan.md +20 -10
- package/examples/skills/codex-5-3-prompting/SKILL.md +161 -0
- package/examples/skills/codex-cli/SKILL.md +16 -8
- package/examples/skills/gpt-5-4-prompting/SKILL.md +202 -0
- package/handoff-utils.ts +92 -0
- package/headless-monitor.ts +16 -3
- package/index.ts +240 -384
- package/notification-utils.ts +134 -0
- package/overlay-component.ts +61 -248
- 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 +6 -74
- package/runtime-coordinator.ts +69 -0
- package/scripts/install.js +18 -3
- package/session-manager.ts +21 -11
- package/session-query.ts +170 -0
- package/spawn-helper.ts +37 -0
- package/tool-schema.ts +6 -2
- package/types.ts +6 -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,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
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";
|
|
@@ -18,6 +16,8 @@ import {
|
|
|
18
16
|
FOOTER_LINES_DIALOG,
|
|
19
17
|
formatDuration,
|
|
20
18
|
} from "./types.js";
|
|
19
|
+
import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
|
|
20
|
+
import { createSessionQueryState, getSessionOutput } from "./session-query.js";
|
|
21
21
|
|
|
22
22
|
export class InteractiveShellOverlay implements Component, Focusable {
|
|
23
23
|
focused = false;
|
|
@@ -39,7 +39,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
39
39
|
private userTookOver = false;
|
|
40
40
|
private handsFreeInterval: ReturnType<typeof setInterval> | null = null;
|
|
41
41
|
private handsFreeInitialTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
-
private startTime
|
|
42
|
+
private startTime: number;
|
|
43
43
|
private sessionId: string | null = null;
|
|
44
44
|
private sessionUnregistered = false;
|
|
45
45
|
// Timeout
|
|
@@ -56,10 +56,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
56
56
|
private hasUnsentData = false;
|
|
57
57
|
// Non-blocking mode: track status for agent queries
|
|
58
58
|
private completionResult: InteractiveShellResult | undefined;
|
|
59
|
-
|
|
60
|
-
private lastQueryTime = 0;
|
|
61
|
-
// Incremental read position (for incremental: true queries)
|
|
62
|
-
private incrementalReadPosition = 0;
|
|
59
|
+
private queryState = createSessionQueryState();
|
|
63
60
|
// Completion callbacks for waiters
|
|
64
61
|
private completeCallbacks: Array<() => void> = [];
|
|
65
62
|
// Simple render throttle to reduce flicker
|
|
@@ -77,6 +74,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
77
74
|
this.options = options;
|
|
78
75
|
this.config = config;
|
|
79
76
|
this.done = done;
|
|
77
|
+
this.startTime = options.startedAt ?? Date.now();
|
|
80
78
|
|
|
81
79
|
const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
|
|
82
80
|
const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
|
|
@@ -84,11 +82,16 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
84
82
|
const rows = Math.max(3, overlayHeight - (HEADER_LINES + FOOTER_LINES_COMPACT + 2));
|
|
85
83
|
|
|
86
84
|
const ptyEvents = {
|
|
87
|
-
onData: () => {
|
|
85
|
+
onData: (data: string) => {
|
|
88
86
|
this.debouncedRender();
|
|
89
|
-
if (this.state === "hands-free" && this.updateMode === "on-quiet") {
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
if (this.state === "hands-free" && (this.updateMode === "on-quiet" || this.options.autoExitOnQuiet)) {
|
|
88
|
+
const visible = stripVTControlCharacters(data);
|
|
89
|
+
if (visible.trim().length > 0) {
|
|
90
|
+
if (this.updateMode === "on-quiet") {
|
|
91
|
+
this.hasUnsentData = true;
|
|
92
|
+
}
|
|
93
|
+
this.resetQuietTimer();
|
|
94
|
+
}
|
|
92
95
|
}
|
|
93
96
|
},
|
|
94
97
|
onExit: () => {
|
|
@@ -203,136 +206,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
203
206
|
|
|
204
207
|
// Public methods for non-blocking mode (agent queries)
|
|
205
208
|
|
|
206
|
-
// Default output limits per status query
|
|
207
|
-
private static readonly DEFAULT_STATUS_OUTPUT = 5 * 1024; // 5KB
|
|
208
|
-
private static readonly DEFAULT_STATUS_LINES = 20;
|
|
209
|
-
private static readonly MAX_STATUS_OUTPUT = 50 * 1024; // 50KB max
|
|
210
|
-
private static readonly MAX_STATUS_LINES = 200; // 200 lines max
|
|
211
|
-
|
|
212
209
|
/** Get rendered terminal output (last N lines, truncated if too large) */
|
|
213
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 } {
|
|
214
|
-
|
|
215
|
-
if (this.completionResult?.completionOutput) {
|
|
216
|
-
const lines = this.completionResult.completionOutput.lines;
|
|
217
|
-
const output = lines.join("\n");
|
|
218
|
-
return {
|
|
219
|
-
output,
|
|
220
|
-
truncated: this.completionResult.completionOutput.truncated,
|
|
221
|
-
totalBytes: output.length,
|
|
222
|
-
totalLines: this.completionResult.completionOutput.totalLines,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
return { output: "", truncated: false, totalBytes: 0 };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Handle legacy boolean parameter
|
|
229
|
-
const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
|
|
230
|
-
const skipRateLimit = opts.skipRateLimit ?? false;
|
|
231
|
-
// Clamp lines and maxChars to valid ranges (1 to MAX)
|
|
232
|
-
const requestedLines = Math.max(1, Math.min(
|
|
233
|
-
opts.lines ?? InteractiveShellOverlay.DEFAULT_STATUS_LINES,
|
|
234
|
-
InteractiveShellOverlay.MAX_STATUS_LINES
|
|
235
|
-
));
|
|
236
|
-
const requestedMaxChars = Math.max(1, Math.min(
|
|
237
|
-
opts.maxChars ?? InteractiveShellOverlay.DEFAULT_STATUS_OUTPUT,
|
|
238
|
-
InteractiveShellOverlay.MAX_STATUS_OUTPUT
|
|
239
|
-
));
|
|
240
|
-
|
|
241
|
-
// Check rate limiting (unless skipped, e.g., for completed sessions)
|
|
242
|
-
if (!skipRateLimit) {
|
|
243
|
-
const now = Date.now();
|
|
244
|
-
const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
|
|
245
|
-
const elapsed = now - this.lastQueryTime;
|
|
246
|
-
|
|
247
|
-
if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
|
|
248
|
-
const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
|
|
249
|
-
return {
|
|
250
|
-
output: "",
|
|
251
|
-
truncated: false,
|
|
252
|
-
totalBytes: 0,
|
|
253
|
-
rateLimited: true,
|
|
254
|
-
waitSeconds,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Update last query time
|
|
259
|
-
this.lastQueryTime = now;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Incremental mode: return next N lines agent hasn't seen yet
|
|
263
|
-
// Server tracks position - agent just keeps calling with incremental: true
|
|
264
|
-
if (opts.incremental) {
|
|
265
|
-
const result = this.session.getLogSlice({
|
|
266
|
-
offset: this.incrementalReadPosition,
|
|
267
|
-
limit: requestedLines,
|
|
268
|
-
stripAnsi: true,
|
|
269
|
-
});
|
|
270
|
-
// Use sliceLineCount directly - handles empty lines correctly
|
|
271
|
-
// (counting newlines in slice fails for empty lines like "")
|
|
272
|
-
const linesFromSlice = result.sliceLineCount;
|
|
273
|
-
// Apply maxChars limit (may truncate mid-line, but we still advance past it)
|
|
274
|
-
const truncatedByChars = result.slice.length > requestedMaxChars;
|
|
275
|
-
const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
|
|
276
|
-
// Update position for next incremental read
|
|
277
|
-
this.incrementalReadPosition += linesFromSlice;
|
|
278
|
-
const hasMore = this.incrementalReadPosition < result.totalLines;
|
|
279
|
-
return {
|
|
280
|
-
output,
|
|
281
|
-
truncated: truncatedByChars,
|
|
282
|
-
totalBytes: output.length,
|
|
283
|
-
totalLines: result.totalLines,
|
|
284
|
-
hasMore,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Drain mode: return only NEW output since last query (raw stream, not lines)
|
|
289
|
-
// This is more token-efficient than re-reading the tail each time
|
|
290
|
-
if (opts.drain) {
|
|
291
|
-
const newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true });
|
|
292
|
-
// Truncate if exceeds maxChars
|
|
293
|
-
const truncated = newOutput.length > requestedMaxChars;
|
|
294
|
-
const output = truncated ? newOutput.slice(-requestedMaxChars) : newOutput;
|
|
295
|
-
return {
|
|
296
|
-
output,
|
|
297
|
-
truncated,
|
|
298
|
-
totalBytes: output.length,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Offset mode: use getLogSlice for pagination through full output
|
|
303
|
-
if (opts.offset !== undefined) {
|
|
304
|
-
const result = this.session.getLogSlice({
|
|
305
|
-
offset: opts.offset,
|
|
306
|
-
limit: requestedLines,
|
|
307
|
-
stripAnsi: true,
|
|
308
|
-
});
|
|
309
|
-
// Apply maxChars limit
|
|
310
|
-
const truncatedByChars = result.slice.length > requestedMaxChars;
|
|
311
|
-
const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
|
|
312
|
-
// Calculate hasMore based on whether there are more lines after this slice
|
|
313
|
-
const hasMore = (opts.offset + result.sliceLineCount) < result.totalLines;
|
|
314
|
-
return {
|
|
315
|
-
output,
|
|
316
|
-
truncated: truncatedByChars || result.sliceLineCount >= requestedLines,
|
|
317
|
-
totalBytes: output.length,
|
|
318
|
-
totalLines: result.totalLines,
|
|
319
|
-
hasMore,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Default: Use rendered terminal output (tail)
|
|
324
|
-
// This gives clean, readable content without TUI animation garbage
|
|
325
|
-
const tailResult = this.session.getTailLines({
|
|
326
|
-
lines: requestedLines,
|
|
327
|
-
ansi: false,
|
|
328
|
-
maxChars: requestedMaxChars,
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
const output = tailResult.lines.join("\n");
|
|
332
|
-
const totalBytes = output.length;
|
|
333
|
-
const truncated = tailResult.lines.length >= requestedLines || tailResult.truncatedByChars;
|
|
334
|
-
|
|
335
|
-
return { output, truncated, totalBytes, totalLines: tailResult.totalLinesInBuffer };
|
|
211
|
+
return getSessionOutput(this.session, this.config, this.queryState, options, this.completionResult?.completionOutput);
|
|
336
212
|
}
|
|
337
213
|
|
|
338
214
|
/** Get current session status */
|
|
@@ -427,13 +303,21 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
427
303
|
}
|
|
428
304
|
}, 2000);
|
|
429
305
|
|
|
306
|
+
if (this.options.autoExitOnQuiet) {
|
|
307
|
+
this.resetQuietTimer();
|
|
308
|
+
}
|
|
309
|
+
|
|
430
310
|
this.handsFreeInterval = setInterval(() => {
|
|
431
311
|
if (this.state === "hands-free") {
|
|
432
312
|
if (this.updateMode === "on-quiet") {
|
|
433
313
|
if (this.hasUnsentData && this.options.onHandsFreeUpdate) {
|
|
434
314
|
this.emitHandsFreeUpdate();
|
|
435
315
|
this.hasUnsentData = false;
|
|
436
|
-
this.
|
|
316
|
+
if (this.options.autoExitOnQuiet) {
|
|
317
|
+
this.resetQuietTimer();
|
|
318
|
+
} else {
|
|
319
|
+
this.stopQuietTimer();
|
|
320
|
+
}
|
|
437
321
|
}
|
|
438
322
|
} else {
|
|
439
323
|
this.emitHandsFreeUpdate();
|
|
@@ -442,7 +326,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
442
326
|
}, this.currentUpdateInterval);
|
|
443
327
|
}
|
|
444
328
|
|
|
445
|
-
/** Reset the quiet timer - called on each data event in on-quiet mode */
|
|
446
329
|
private resetQuietTimer(): void {
|
|
447
330
|
this.stopQuietTimer();
|
|
448
331
|
this.quietTimer = setTimeout(() => {
|
|
@@ -510,7 +393,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
510
393
|
if (this.hasUnsentData && this.options.onHandsFreeUpdate) {
|
|
511
394
|
this.emitHandsFreeUpdate();
|
|
512
395
|
this.hasUnsentData = false;
|
|
513
|
-
this.
|
|
396
|
+
if (this.options.autoExitOnQuiet) {
|
|
397
|
+
this.resetQuietTimer();
|
|
398
|
+
} else {
|
|
399
|
+
this.stopQuietTimer();
|
|
400
|
+
}
|
|
514
401
|
}
|
|
515
402
|
} else {
|
|
516
403
|
this.emitHandsFreeUpdate();
|
|
@@ -526,9 +413,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
526
413
|
if (clamped === this.currentQuietThreshold) return;
|
|
527
414
|
this.currentQuietThreshold = clamped;
|
|
528
415
|
|
|
529
|
-
|
|
530
|
-
// Use resetQuietTimer to ensure autoExitOnQuiet logic is included
|
|
531
|
-
if (this.quietTimer && this.updateMode === "on-quiet") {
|
|
416
|
+
if (this.quietTimer) {
|
|
532
417
|
this.resetQuietTimer();
|
|
533
418
|
}
|
|
534
419
|
}
|
|
@@ -624,8 +509,6 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
624
509
|
this.state = "running";
|
|
625
510
|
this.userTookOver = true;
|
|
626
511
|
|
|
627
|
-
// Notify agent that user took over (streaming mode)
|
|
628
|
-
// In non-blocking mode, keep session registered so agent can query status
|
|
629
512
|
if (this.options.onHandsFreeUpdate) {
|
|
630
513
|
this.options.onHandsFreeUpdate({
|
|
631
514
|
status: "user-takeover",
|
|
@@ -637,103 +520,35 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
637
520
|
totalCharsSent: this.totalCharsSent,
|
|
638
521
|
budgetExhausted: this.budgetExhausted,
|
|
639
522
|
});
|
|
640
|
-
|
|
523
|
+
}
|
|
524
|
+
// In streaming mode (blocking tool call), unregister now since the agent
|
|
525
|
+
// gets the result via tool return. Otherwise keep registered for queries.
|
|
526
|
+
if (this.options.streamingMode) {
|
|
641
527
|
this.unregisterActiveSession(true);
|
|
642
528
|
}
|
|
643
|
-
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
644
|
-
// so agent can query and see "user-takeover" status
|
|
645
529
|
|
|
646
530
|
this.tui.requestRender();
|
|
647
531
|
}
|
|
648
532
|
|
|
649
533
|
/** Capture output for dispatch completion notifications */
|
|
650
534
|
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
|
|
651
|
-
|
|
652
|
-
lines: this.config.completionNotifyLines,
|
|
653
|
-
ansi: false,
|
|
654
|
-
maxChars: this.config.completionNotifyMaxChars,
|
|
655
|
-
});
|
|
656
|
-
return {
|
|
657
|
-
lines: result.lines,
|
|
658
|
-
totalLines: result.totalLinesInBuffer,
|
|
659
|
-
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
|
|
660
|
-
};
|
|
535
|
+
return captureCompletionOutput(this.session, this.config);
|
|
661
536
|
}
|
|
662
537
|
|
|
663
538
|
/** Capture output for transfer action (Ctrl+T or dialog) */
|
|
664
539
|
private captureTransferOutput(): InteractiveShellResult["transferred"] {
|
|
665
|
-
|
|
666
|
-
const maxChars = this.config.transferMaxChars;
|
|
667
|
-
|
|
668
|
-
const result = this.session.getTailLines({
|
|
669
|
-
lines: maxLines,
|
|
670
|
-
ansi: false,
|
|
671
|
-
maxChars,
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
|
|
675
|
-
|
|
676
|
-
return {
|
|
677
|
-
lines: result.lines,
|
|
678
|
-
totalLines: result.totalLinesInBuffer,
|
|
679
|
-
truncated,
|
|
680
|
-
};
|
|
540
|
+
return captureTransferOutput(this.session, this.config);
|
|
681
541
|
}
|
|
682
542
|
|
|
683
543
|
private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
|
|
684
|
-
|
|
685
|
-
if (!enabled) return undefined;
|
|
686
|
-
|
|
687
|
-
const lines = this.options.handoffPreviewLines ?? this.config.handoffPreviewLines;
|
|
688
|
-
const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
|
|
689
|
-
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
690
|
-
|
|
691
|
-
const result = this.session.getTailLines({
|
|
692
|
-
lines,
|
|
693
|
-
ansi: false,
|
|
694
|
-
maxChars,
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
return { type: "tail", when, lines: result.lines };
|
|
544
|
+
return maybeBuildHandoffPreview(this.session, when, this.config, this.options);
|
|
698
545
|
}
|
|
699
546
|
|
|
700
547
|
private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const maxChars = this.options.handoffSnapshotMaxChars ?? this.config.handoffSnapshotMaxChars;
|
|
706
|
-
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
707
|
-
|
|
708
|
-
const baseDir = join(homedir(), ".pi", "agent", "cache", "interactive-shell");
|
|
709
|
-
mkdirSync(baseDir, { recursive: true });
|
|
710
|
-
|
|
711
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
712
|
-
const pid = this.session.pid;
|
|
713
|
-
const filename = `snapshot-${timestamp}-pid${pid}.log`;
|
|
714
|
-
const transcriptPath = join(baseDir, filename);
|
|
715
|
-
|
|
716
|
-
const tailResult = this.session.getTailLines({
|
|
717
|
-
lines,
|
|
718
|
-
ansi: this.config.ansiReemit,
|
|
719
|
-
maxChars,
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
const header = [
|
|
723
|
-
`# interactive-shell snapshot (${when})`,
|
|
724
|
-
`time: ${new Date().toISOString()}`,
|
|
725
|
-
`command: ${this.options.command}`,
|
|
726
|
-
`cwd: ${this.options.cwd ?? ""}`,
|
|
727
|
-
`pid: ${pid}`,
|
|
728
|
-
`exitCode: ${this.session.exitCode ?? ""}`,
|
|
729
|
-
`signal: ${this.session.signal ?? ""}`,
|
|
730
|
-
`lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
|
|
731
|
-
"",
|
|
732
|
-
].join("\n");
|
|
733
|
-
|
|
734
|
-
writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
|
|
735
|
-
|
|
736
|
-
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);
|
|
737
552
|
}
|
|
738
553
|
|
|
739
554
|
private finishWithExit(): void {
|
|
@@ -761,10 +576,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
761
576
|
this.completionResult = result;
|
|
762
577
|
this.triggerCompleteCallbacks();
|
|
763
578
|
|
|
764
|
-
// In
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
if (this.options.onHandsFreeUpdate) {
|
|
579
|
+
// In streaming mode (blocking tool call), unregister now since the agent
|
|
580
|
+
// gets the result via tool return. Otherwise keep registered for queries.
|
|
581
|
+
if (this.options.streamingMode) {
|
|
768
582
|
this.unregisterActiveSession(true);
|
|
769
583
|
}
|
|
770
584
|
|
|
@@ -785,7 +599,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
785
599
|
const handoffPreview = this.maybeBuildHandoffPreview("detach");
|
|
786
600
|
const handoff = this.maybeWriteHandoffSnapshot("detach");
|
|
787
601
|
const addOptions = this.sessionId
|
|
788
|
-
? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch" }
|
|
602
|
+
? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch", startedAt: new Date(this.startTime) }
|
|
789
603
|
: undefined;
|
|
790
604
|
const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions);
|
|
791
605
|
const result: InteractiveShellResult = {
|
|
@@ -801,10 +615,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
801
615
|
this.completionResult = result;
|
|
802
616
|
this.triggerCompleteCallbacks();
|
|
803
617
|
|
|
804
|
-
// In
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
if (this.options.onHandsFreeUpdate) {
|
|
618
|
+
// In streaming mode (blocking tool call), unregister now since the agent
|
|
619
|
+
// gets the result via tool return. releaseId=false because background owns the ID.
|
|
620
|
+
if (this.options.streamingMode) {
|
|
808
621
|
this.unregisterActiveSession(false);
|
|
809
622
|
}
|
|
810
623
|
|
|
@@ -836,9 +649,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
836
649
|
this.completionResult = result;
|
|
837
650
|
this.triggerCompleteCallbacks();
|
|
838
651
|
|
|
839
|
-
// In
|
|
840
|
-
//
|
|
841
|
-
if (this.options.
|
|
652
|
+
// In streaming mode (blocking tool call), unregister now since the agent
|
|
653
|
+
// gets the result via tool return. Otherwise keep registered for queries.
|
|
654
|
+
if (this.options.streamingMode) {
|
|
842
655
|
this.unregisterActiveSession(true);
|
|
843
656
|
}
|
|
844
657
|
|
|
@@ -875,9 +688,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
875
688
|
this.completionResult = result;
|
|
876
689
|
this.triggerCompleteCallbacks();
|
|
877
690
|
|
|
878
|
-
// In
|
|
879
|
-
//
|
|
880
|
-
if (this.options.
|
|
691
|
+
// In streaming mode (blocking tool call), unregister now since the agent
|
|
692
|
+
// gets the result via tool return. Otherwise keep registered for queries.
|
|
693
|
+
if (this.options.streamingMode) {
|
|
881
694
|
this.unregisterActiveSession(true);
|
|
882
695
|
}
|
|
883
696
|
|
|
@@ -929,9 +742,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
929
742
|
this.completionResult = result;
|
|
930
743
|
this.triggerCompleteCallbacks();
|
|
931
744
|
|
|
932
|
-
// In
|
|
933
|
-
//
|
|
934
|
-
if (this.options.
|
|
745
|
+
// In streaming mode (blocking tool call), unregister now since the agent
|
|
746
|
+
// gets the result via tool return. Otherwise keep registered for queries.
|
|
747
|
+
if (this.options.streamingMode) {
|
|
935
748
|
this.unregisterActiveSession(true);
|
|
936
749
|
}
|
|
937
750
|
|
|
@@ -1040,6 +853,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
1040
853
|
}
|
|
1041
854
|
|
|
1042
855
|
render(width: number): string[] {
|
|
856
|
+
width = Math.max(4, width);
|
|
1043
857
|
const th = this.theme;
|
|
1044
858
|
const border = (s: string) => th.fg("border", s);
|
|
1045
859
|
const accent = (s: string) => th.fg("accent", s);
|
|
@@ -1176,10 +990,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
1176
990
|
if (!this.completionResult) {
|
|
1177
991
|
this.session.kill();
|
|
1178
992
|
this.session.dispose();
|
|
1179
|
-
// Release ID since session is dead and agent can't query anymore
|
|
1180
993
|
this.unregisterActiveSession(true);
|
|
1181
|
-
} else if (this.options.
|
|
1182
|
-
// Streaming mode already delivered result, safe to
|
|
994
|
+
} else if (this.options.streamingMode) {
|
|
995
|
+
// Streaming mode already delivered result via tool return, safe to clean up
|
|
1183
996
|
this.unregisterActiveSession(true);
|
|
1184
997
|
}
|
|
1185
998
|
// Non-blocking mode with completion: keep registered so agent can query
|
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.0",
|
|
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
|
}
|