pi-interactive-shell 0.6.3 → 0.7.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 +37 -0
- package/README.md +101 -12
- package/SKILL.md +86 -17
- package/config.ts +10 -0
- package/headless-monitor.ts +171 -0
- package/index.ts +745 -367
- package/overlay-component.ts +134 -72
- package/package.json +2 -1
- package/pty-session.ts +36 -4
- package/reattach-overlay.ts +31 -4
- package/session-manager.ts +94 -45
- package/tool-schema.ts +61 -2
- package/types.ts +11 -3
package/index.ts
CHANGED
|
@@ -1,19 +1,226 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
2
3
|
import { InteractiveShellOverlay } from "./overlay-component.js";
|
|
3
4
|
import { ReattachOverlay } from "./reattach-overlay.js";
|
|
5
|
+
import { PtyTerminalSession } from "./pty-session.js";
|
|
4
6
|
import type { InteractiveShellResult } from "./types.js";
|
|
5
|
-
import { sessionManager, generateSessionId } from "./session-manager.js";
|
|
7
|
+
import { sessionManager, generateSessionId, releaseSessionId } from "./session-manager.js";
|
|
8
|
+
import type { OutputOptions, OutputResult } from "./session-manager.js";
|
|
6
9
|
import { loadConfig } from "./config.js";
|
|
10
|
+
import type { InteractiveShellConfig } from "./config.js";
|
|
7
11
|
import { translateInput } from "./key-encoding.js";
|
|
8
12
|
import { TOOL_NAME, TOOL_LABEL, TOOL_DESCRIPTION, toolParameters, type ToolParams } from "./tool-schema.js";
|
|
9
13
|
import { formatDuration, formatDurationMs } from "./types.js";
|
|
14
|
+
import { HeadlessDispatchMonitor } from "./headless-monitor.js";
|
|
15
|
+
import type { HeadlessCompletionInfo } from "./headless-monitor.js";
|
|
10
16
|
|
|
11
|
-
// Track whether an overlay is currently open to prevent stacking
|
|
12
17
|
let overlayOpen = false;
|
|
18
|
+
let agentHandledCompletion = false;
|
|
19
|
+
const headlessMonitors = new Map<string, HeadlessDispatchMonitor>();
|
|
20
|
+
|
|
21
|
+
function getHeadlessOutput(session: PtyTerminalSession, opts?: OutputOptions | boolean): OutputResult {
|
|
22
|
+
const options = typeof opts === "boolean" ? {} : (opts ?? {});
|
|
23
|
+
const lines = options.lines ?? 20;
|
|
24
|
+
const maxChars = options.maxChars ?? 5 * 1024;
|
|
25
|
+
try {
|
|
26
|
+
const result = session.getTailLines({ lines, ansi: false, maxChars });
|
|
27
|
+
const output = result.lines.join("\n");
|
|
28
|
+
return {
|
|
29
|
+
output,
|
|
30
|
+
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
|
|
31
|
+
totalBytes: output.length,
|
|
32
|
+
totalLines: result.totalLinesInBuffer,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { output: "", truncated: false, totalBytes: 0 };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BRIEF_TAIL_LINES = 5;
|
|
40
|
+
|
|
41
|
+
function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
if (info.timedOut) {
|
|
44
|
+
parts.push(`Session ${sessionId} timed out (${duration}).`);
|
|
45
|
+
} else if (info.cancelled) {
|
|
46
|
+
parts.push(`Session ${sessionId} completed (${duration}).`);
|
|
47
|
+
} else if (info.exitCode === 0) {
|
|
48
|
+
parts.push(`Session ${sessionId} completed successfully (${duration}).`);
|
|
49
|
+
} else {
|
|
50
|
+
parts.push(`Session ${sessionId} exited with code ${info.exitCode} (${duration}).`);
|
|
51
|
+
}
|
|
52
|
+
if (info.completionOutput && info.completionOutput.totalLines > 0) {
|
|
53
|
+
parts.push(` ${info.completionOutput.totalLines} lines of output.`);
|
|
54
|
+
}
|
|
55
|
+
if (info.completionOutput && info.completionOutput.lines.length > 0) {
|
|
56
|
+
const allLines = info.completionOutput.lines;
|
|
57
|
+
let end = allLines.length;
|
|
58
|
+
while (end > 0 && allLines[end - 1].trim() === "") end--;
|
|
59
|
+
const tail = allLines.slice(Math.max(0, end - BRIEF_TAIL_LINES), end);
|
|
60
|
+
if (tail.length > 0) {
|
|
61
|
+
parts.push(`\n\n${tail.join("\n")}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
|
|
65
|
+
return parts.join("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
|
|
69
|
+
const parts: string[] = [];
|
|
70
|
+
if (result.timedOut) {
|
|
71
|
+
parts.push(`Session ${sessionId} timed out.`);
|
|
72
|
+
} else if (result.cancelled) {
|
|
73
|
+
parts.push(`Session ${sessionId} was killed.`);
|
|
74
|
+
} else if (result.exitCode === 0) {
|
|
75
|
+
parts.push(`Session ${sessionId} completed successfully.`);
|
|
76
|
+
} else {
|
|
77
|
+
parts.push(`Session ${sessionId} exited with code ${result.exitCode}.`);
|
|
78
|
+
}
|
|
79
|
+
if (result.completionOutput && result.completionOutput.lines.length > 0) {
|
|
80
|
+
const truncNote = result.completionOutput.truncated
|
|
81
|
+
? ` (truncated from ${result.completionOutput.totalLines} total lines)`
|
|
82
|
+
: "";
|
|
83
|
+
parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
|
|
84
|
+
}
|
|
85
|
+
return parts.join("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeMonitorCompletionCallback(
|
|
89
|
+
pi: ExtensionAPI,
|
|
90
|
+
id: string,
|
|
91
|
+
startTime: number,
|
|
92
|
+
): (info: HeadlessCompletionInfo) => void {
|
|
93
|
+
return (info) => {
|
|
94
|
+
const duration = formatDuration(Date.now() - startTime);
|
|
95
|
+
const content = buildDispatchNotification(id, info, duration);
|
|
96
|
+
pi.sendMessage({
|
|
97
|
+
customType: "interactive-shell-transfer",
|
|
98
|
+
content,
|
|
99
|
+
display: true,
|
|
100
|
+
details: { sessionId: id, duration, ...info },
|
|
101
|
+
}, { triggerTurn: true });
|
|
102
|
+
pi.events.emit("interactive-shell:transfer", { sessionId: id, ...info });
|
|
103
|
+
sessionManager.unregisterActive(id, false);
|
|
104
|
+
headlessMonitors.delete(id);
|
|
105
|
+
sessionManager.scheduleCleanup(id, 5 * 60 * 1000);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function registerHeadlessActive(
|
|
110
|
+
id: string,
|
|
111
|
+
command: string,
|
|
112
|
+
reason: string | undefined,
|
|
113
|
+
session: PtyTerminalSession,
|
|
114
|
+
monitor: HeadlessDispatchMonitor,
|
|
115
|
+
startTime: number,
|
|
116
|
+
): void {
|
|
117
|
+
sessionManager.registerActive({
|
|
118
|
+
id,
|
|
119
|
+
command,
|
|
120
|
+
reason,
|
|
121
|
+
write: (data) => session.write(data),
|
|
122
|
+
kill: () => {
|
|
123
|
+
monitor.dispose();
|
|
124
|
+
sessionManager.remove(id);
|
|
125
|
+
sessionManager.unregisterActive(id, true);
|
|
126
|
+
headlessMonitors.delete(id);
|
|
127
|
+
},
|
|
128
|
+
background: () => {},
|
|
129
|
+
getOutput: (opts) => getHeadlessOutput(session, opts),
|
|
130
|
+
getStatus: () => session.exited ? "exited" : "running",
|
|
131
|
+
getRuntime: () => Date.now() - startTime,
|
|
132
|
+
getResult: () => monitor.getResult(),
|
|
133
|
+
onComplete: (cb) => monitor.registerCompleteCallback(cb),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let bgWidgetCleanup: (() => void) | null = null;
|
|
138
|
+
|
|
139
|
+
function setupBackgroundWidget(ctx: { ui: { setWidget: Function }; hasUI?: boolean }) {
|
|
140
|
+
if (!ctx.hasUI) return;
|
|
141
|
+
|
|
142
|
+
bgWidgetCleanup?.();
|
|
143
|
+
|
|
144
|
+
let durationTimer: ReturnType<typeof setInterval> | null = null;
|
|
145
|
+
let tuiRef: { requestRender: () => void } | null = null;
|
|
146
|
+
|
|
147
|
+
const requestRender = () => tuiRef?.requestRender();
|
|
148
|
+
|
|
149
|
+
const unsubscribe = sessionManager.onChange(() => {
|
|
150
|
+
manageDurationTimer();
|
|
151
|
+
requestRender();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function manageDurationTimer() {
|
|
155
|
+
const sessions = sessionManager.list();
|
|
156
|
+
const hasRunning = sessions.some((s) => !s.session.exited);
|
|
157
|
+
if (hasRunning && !durationTimer) {
|
|
158
|
+
durationTimer = setInterval(requestRender, 10_000);
|
|
159
|
+
} else if (!hasRunning && durationTimer) {
|
|
160
|
+
clearInterval(durationTimer);
|
|
161
|
+
durationTimer = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ctx.ui.setWidget(
|
|
166
|
+
"bg-sessions",
|
|
167
|
+
(tui: any, theme: any) => {
|
|
168
|
+
tuiRef = tui;
|
|
169
|
+
return {
|
|
170
|
+
render: (width: number) => {
|
|
171
|
+
const sessions = sessionManager.list();
|
|
172
|
+
if (sessions.length === 0) return [];
|
|
173
|
+
const cols = width || tui.terminal?.columns || 120;
|
|
174
|
+
const lines: string[] = [];
|
|
175
|
+
for (const s of sessions) {
|
|
176
|
+
const exited = s.session.exited;
|
|
177
|
+
const dot = exited ? theme.fg("dim", "○") : theme.fg("accent", "●");
|
|
178
|
+
const id = theme.fg("dim", s.id);
|
|
179
|
+
const cmd = s.command.replace(/\s+/g, " ").trim();
|
|
180
|
+
const truncCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
|
|
181
|
+
const reason = s.reason ? theme.fg("dim", ` · ${s.reason}`) : "";
|
|
182
|
+
const status = exited ? theme.fg("dim", "exited") : theme.fg("success", "running");
|
|
183
|
+
const duration = theme.fg("dim", formatDuration(Date.now() - s.startedAt.getTime()));
|
|
184
|
+
const oneLine = ` ${dot} ${id} ${truncCmd}${reason} ${status} ${duration}`;
|
|
185
|
+
if (visibleWidth(oneLine) <= cols) {
|
|
186
|
+
lines.push(oneLine);
|
|
187
|
+
} else {
|
|
188
|
+
lines.push(truncateToWidth(` ${dot} ${id} ${cmd}`, cols, "…"));
|
|
189
|
+
lines.push(truncateToWidth(` ${status} ${duration}${reason}`, cols, "…"));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return lines;
|
|
193
|
+
},
|
|
194
|
+
invalidate: () => {},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
{ placement: "belowEditor" },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
manageDurationTimer();
|
|
201
|
+
|
|
202
|
+
bgWidgetCleanup = () => {
|
|
203
|
+
unsubscribe();
|
|
204
|
+
if (durationTimer) {
|
|
205
|
+
clearInterval(durationTimer);
|
|
206
|
+
durationTimer = null;
|
|
207
|
+
}
|
|
208
|
+
ctx.ui.setWidget("bg-sessions", undefined);
|
|
209
|
+
bgWidgetCleanup = null;
|
|
210
|
+
};
|
|
211
|
+
}
|
|
13
212
|
|
|
14
213
|
export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
214
|
+
pi.on("session_start", (_event, ctx) => setupBackgroundWidget(ctx));
|
|
215
|
+
pi.on("session_switch", (_event, ctx) => setupBackgroundWidget(ctx));
|
|
216
|
+
|
|
15
217
|
pi.on("session_shutdown", () => {
|
|
218
|
+
bgWidgetCleanup?.();
|
|
16
219
|
sessionManager.killAll();
|
|
220
|
+
for (const [id, monitor] of headlessMonitors) {
|
|
221
|
+
monitor.dispose();
|
|
222
|
+
headlessMonitors.delete(id);
|
|
223
|
+
}
|
|
17
224
|
});
|
|
18
225
|
|
|
19
226
|
pi.registerTool({
|
|
@@ -22,7 +229,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
22
229
|
description: TOOL_DESCRIPTION,
|
|
23
230
|
parameters: toolParameters,
|
|
24
231
|
|
|
25
|
-
async execute(_toolCallId, params, onUpdate, ctx) {
|
|
232
|
+
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
26
233
|
const {
|
|
27
234
|
command,
|
|
28
235
|
sessionId,
|
|
@@ -41,19 +248,22 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
41
248
|
name,
|
|
42
249
|
reason,
|
|
43
250
|
mode,
|
|
251
|
+
background,
|
|
252
|
+
attach,
|
|
253
|
+
listBackground,
|
|
254
|
+
dismissBackground,
|
|
44
255
|
handsFree,
|
|
45
256
|
handoffPreview,
|
|
46
257
|
handoffSnapshot,
|
|
47
258
|
timeout,
|
|
48
259
|
} = params as ToolParams;
|
|
49
260
|
|
|
50
|
-
// Build structured input from separate fields if any are provided
|
|
51
261
|
const hasStructuredInput = inputKeys?.length || inputHex?.length || inputPaste;
|
|
52
262
|
const effectiveInput = hasStructuredInput
|
|
53
263
|
? { text: input, keys: inputKeys, hex: inputHex, paste: inputPaste }
|
|
54
264
|
: input;
|
|
55
265
|
|
|
56
|
-
//
|
|
266
|
+
// ── Branch 1: Interact with existing session ──
|
|
57
267
|
if (sessionId) {
|
|
58
268
|
const session = sessionManager.getActive(sessionId);
|
|
59
269
|
if (!session) {
|
|
@@ -64,8 +274,12 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
64
274
|
};
|
|
65
275
|
}
|
|
66
276
|
|
|
67
|
-
// Kill
|
|
277
|
+
// Kill
|
|
68
278
|
if (kill) {
|
|
279
|
+
const hMonitor = headlessMonitors.get(sessionId);
|
|
280
|
+
if (!hMonitor || hMonitor.disposed) {
|
|
281
|
+
agentHandledCompletion = true;
|
|
282
|
+
}
|
|
69
283
|
const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
70
284
|
const status = session.getStatus();
|
|
71
285
|
const runtime = session.getRuntime();
|
|
@@ -75,47 +289,55 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
75
289
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
76
290
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
77
291
|
return {
|
|
78
|
-
content: [
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
292
|
+
content: [{ type: "text", text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
293
|
+
details: { sessionId, status: "killed", runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, previousStatus: status },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Background
|
|
298
|
+
if (background) {
|
|
299
|
+
if (session.getResult()) {
|
|
300
|
+
return {
|
|
301
|
+
content: [{ type: "text", text: "Session already completed." }],
|
|
302
|
+
details: session.getResult(),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const bMonitor = headlessMonitors.get(sessionId);
|
|
306
|
+
if (!bMonitor || bMonitor.disposed) {
|
|
307
|
+
agentHandledCompletion = true;
|
|
308
|
+
}
|
|
309
|
+
session.background();
|
|
310
|
+
const result = session.getResult();
|
|
311
|
+
if (!result || !result.backgrounded) {
|
|
312
|
+
agentHandledCompletion = false;
|
|
313
|
+
return {
|
|
314
|
+
content: [{ type: "text", text: `Session ${sessionId} is already running in the background.` }],
|
|
315
|
+
details: { sessionId },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
sessionManager.unregisterActive(sessionId, false);
|
|
319
|
+
return {
|
|
320
|
+
content: [{ type: "text", text: `Session backgrounded (id: ${result.backgroundId})` }],
|
|
321
|
+
details: { sessionId, backgroundId: result.backgroundId, ...result },
|
|
95
322
|
};
|
|
96
323
|
}
|
|
97
324
|
|
|
98
325
|
const actions: string[] = [];
|
|
99
326
|
|
|
100
|
-
// Apply settings changes
|
|
101
327
|
if (settings?.updateInterval !== undefined) {
|
|
102
|
-
|
|
103
|
-
if (changed) {
|
|
328
|
+
if (sessionManager.setActiveUpdateInterval(sessionId, settings.updateInterval)) {
|
|
104
329
|
actions.push(`update interval set to ${settings.updateInterval}ms`);
|
|
105
330
|
}
|
|
106
331
|
}
|
|
107
332
|
if (settings?.quietThreshold !== undefined) {
|
|
108
|
-
|
|
109
|
-
if (changed) {
|
|
333
|
+
if (sessionManager.setActiveQuietThreshold(sessionId, settings.quietThreshold)) {
|
|
110
334
|
actions.push(`quiet threshold set to ${settings.quietThreshold}ms`);
|
|
111
335
|
}
|
|
112
336
|
}
|
|
113
337
|
|
|
114
|
-
// Send input if provided
|
|
115
338
|
if (effectiveInput !== undefined) {
|
|
116
339
|
const translatedInput = translateInput(effectiveInput);
|
|
117
340
|
const success = sessionManager.writeToActive(sessionId, translatedInput);
|
|
118
|
-
|
|
119
341
|
if (!success) {
|
|
120
342
|
return {
|
|
121
343
|
content: [{ type: "text", text: `Failed to send input to session: ${sessionId}` }],
|
|
@@ -123,86 +345,42 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
123
345
|
details: { sessionId, error: "write_failed" },
|
|
124
346
|
};
|
|
125
347
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
? effectiveInput.length === 0
|
|
130
|
-
? "(empty)"
|
|
131
|
-
: effectiveInput.length > 50
|
|
132
|
-
? `${effectiveInput.slice(0, 50)}...`
|
|
133
|
-
: effectiveInput
|
|
134
|
-
: [
|
|
135
|
-
effectiveInput.text ?? "",
|
|
136
|
-
effectiveInput.keys ? `keys:[${effectiveInput.keys.join(",")}]` : "",
|
|
137
|
-
effectiveInput.hex ? `hex:[${effectiveInput.hex.length} bytes]` : "",
|
|
138
|
-
effectiveInput.paste ? `paste:[${effectiveInput.paste.length} chars]` : "",
|
|
139
|
-
]
|
|
140
|
-
.filter(Boolean)
|
|
141
|
-
.join(" + ") || "(empty)";
|
|
142
|
-
|
|
348
|
+
const inputDesc = typeof effectiveInput === "string"
|
|
349
|
+
? effectiveInput.length === 0 ? "(empty)" : effectiveInput.length > 50 ? `${effectiveInput.slice(0, 50)}...` : effectiveInput
|
|
350
|
+
: [effectiveInput.text ?? "", effectiveInput.keys ? `keys:[${effectiveInput.keys.join(",")}]` : "", effectiveInput.hex ? `hex:[${effectiveInput.hex.length} bytes]` : "", effectiveInput.paste ? `paste:[${effectiveInput.paste.length} chars]` : ""].filter(Boolean).join(" + ") || "(empty)";
|
|
143
351
|
actions.push(`sent: ${inputDesc}`);
|
|
144
352
|
}
|
|
145
353
|
|
|
146
|
-
// If only querying status (no input, no settings, no kill)
|
|
147
354
|
if (actions.length === 0) {
|
|
148
355
|
const status = session.getStatus();
|
|
149
356
|
const runtime = session.getRuntime();
|
|
150
357
|
const result = session.getResult();
|
|
151
358
|
|
|
152
|
-
// If session completed, always allow query (no rate limiting)
|
|
153
|
-
// Rate limiting only applies to "checking in" on running sessions
|
|
154
359
|
if (result) {
|
|
155
360
|
const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
156
361
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
157
362
|
const hasOutput = output.length > 0;
|
|
158
363
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
159
|
-
|
|
160
|
-
sessionManager.unregisterActive(sessionId, true);
|
|
364
|
+
sessionManager.unregisterActive(sessionId, !result.backgrounded);
|
|
161
365
|
return {
|
|
162
|
-
content: [
|
|
163
|
-
|
|
164
|
-
type: "text",
|
|
165
|
-
text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}`,
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
details: {
|
|
169
|
-
sessionId,
|
|
170
|
-
status,
|
|
171
|
-
runtime,
|
|
172
|
-
output,
|
|
173
|
-
outputTruncated: truncated,
|
|
174
|
-
outputTotalBytes: totalBytes,
|
|
175
|
-
outputTotalLines: totalLines,
|
|
176
|
-
hasMore,
|
|
177
|
-
exitCode: result.exitCode,
|
|
178
|
-
signal: result.signal,
|
|
179
|
-
backgroundId: result.backgroundId,
|
|
180
|
-
},
|
|
366
|
+
content: [{ type: "text", text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
367
|
+
details: { sessionId, status, runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, exitCode: result.exitCode, signal: result.signal, backgroundId: result.backgroundId },
|
|
181
368
|
};
|
|
182
369
|
}
|
|
183
370
|
|
|
184
|
-
// Session still running - check rate limiting
|
|
185
371
|
const outputResult = session.getOutput({ lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
186
372
|
|
|
187
|
-
// If rate limited, wait until allowed then return fresh result
|
|
188
|
-
// Use Promise.race to detect if session completes during wait
|
|
189
373
|
if (outputResult.rateLimited && outputResult.waitSeconds) {
|
|
190
374
|
const waitMs = outputResult.waitSeconds * 1000;
|
|
191
|
-
|
|
192
|
-
// Race: rate limit timeout vs session completion
|
|
193
375
|
const completedEarly = await Promise.race([
|
|
194
376
|
new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
|
|
195
377
|
new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
|
|
196
378
|
]);
|
|
197
|
-
|
|
198
|
-
// If session completed during wait, return result immediately
|
|
379
|
+
|
|
199
380
|
if (completedEarly) {
|
|
200
381
|
const earlySession = sessionManager.getActive(sessionId);
|
|
201
382
|
if (!earlySession) {
|
|
202
|
-
return {
|
|
203
|
-
content: [{ type: "text", text: `Session ${sessionId} ended` }],
|
|
204
|
-
details: { sessionId, status: "ended" },
|
|
205
|
-
};
|
|
383
|
+
return { content: [{ type: "text", text: `Session ${sessionId} ended` }], details: { sessionId, status: "ended" } };
|
|
206
384
|
}
|
|
207
385
|
const earlyResult = earlySession.getResult();
|
|
208
386
|
const { output, truncated, totalBytes, totalLines, hasMore } = earlySession.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
@@ -211,54 +389,19 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
211
389
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
212
390
|
const hasOutput = output.length > 0;
|
|
213
391
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
214
|
-
|
|
215
392
|
if (earlyResult) {
|
|
216
|
-
sessionManager.unregisterActive(sessionId,
|
|
393
|
+
sessionManager.unregisterActive(sessionId, !earlyResult.backgrounded);
|
|
217
394
|
return {
|
|
218
|
-
content: [
|
|
219
|
-
|
|
220
|
-
type: "text",
|
|
221
|
-
text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}`,
|
|
222
|
-
},
|
|
223
|
-
],
|
|
224
|
-
details: {
|
|
225
|
-
sessionId,
|
|
226
|
-
status: earlyStatus,
|
|
227
|
-
runtime: earlyRuntime,
|
|
228
|
-
output,
|
|
229
|
-
outputTruncated: truncated,
|
|
230
|
-
outputTotalBytes: totalBytes,
|
|
231
|
-
outputTotalLines: totalLines,
|
|
232
|
-
hasMore,
|
|
233
|
-
exitCode: earlyResult.exitCode,
|
|
234
|
-
signal: earlyResult.signal,
|
|
235
|
-
backgroundId: earlyResult.backgroundId,
|
|
236
|
-
},
|
|
395
|
+
content: [{ type: "text", text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
396
|
+
details: { sessionId, status: earlyStatus, runtime: earlyRuntime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, exitCode: earlyResult.exitCode, signal: earlyResult.signal, backgroundId: earlyResult.backgroundId },
|
|
237
397
|
};
|
|
238
398
|
}
|
|
239
|
-
// Edge case: onComplete fired but no result yet (shouldn't happen)
|
|
240
|
-
// Return current status without unregistering
|
|
241
399
|
return {
|
|
242
|
-
content: [
|
|
243
|
-
|
|
244
|
-
type: "text",
|
|
245
|
-
text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}`,
|
|
246
|
-
},
|
|
247
|
-
],
|
|
248
|
-
details: {
|
|
249
|
-
sessionId,
|
|
250
|
-
status: earlyStatus,
|
|
251
|
-
runtime: earlyRuntime,
|
|
252
|
-
output,
|
|
253
|
-
outputTruncated: truncated,
|
|
254
|
-
outputTotalBytes: totalBytes,
|
|
255
|
-
outputTotalLines: totalLines,
|
|
256
|
-
hasMore,
|
|
257
|
-
hasOutput,
|
|
258
|
-
},
|
|
400
|
+
content: [{ type: "text", text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
401
|
+
details: { sessionId, status: earlyStatus, runtime: earlyRuntime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, hasOutput },
|
|
259
402
|
};
|
|
260
403
|
}
|
|
261
|
-
|
|
404
|
+
|
|
262
405
|
const freshOutput = session.getOutput({ lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
263
406
|
const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
|
|
264
407
|
const hasOutput = freshOutput.output.length > 0;
|
|
@@ -266,78 +409,26 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
266
409
|
const freshStatus = session.getStatus();
|
|
267
410
|
const freshRuntime = session.getRuntime();
|
|
268
411
|
const freshResult = session.getResult();
|
|
269
|
-
|
|
270
412
|
if (freshResult) {
|
|
271
|
-
sessionManager.unregisterActive(sessionId,
|
|
413
|
+
sessionManager.unregisterActive(sessionId, !freshResult.backgrounded);
|
|
272
414
|
return {
|
|
273
|
-
content: [
|
|
274
|
-
|
|
275
|
-
type: "text",
|
|
276
|
-
text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}`,
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
details: {
|
|
280
|
-
sessionId,
|
|
281
|
-
status: freshStatus,
|
|
282
|
-
runtime: freshRuntime,
|
|
283
|
-
output: freshOutput.output,
|
|
284
|
-
outputTruncated: freshOutput.truncated,
|
|
285
|
-
outputTotalBytes: freshOutput.totalBytes,
|
|
286
|
-
outputTotalLines: freshOutput.totalLines,
|
|
287
|
-
hasMore: freshOutput.hasMore,
|
|
288
|
-
exitCode: freshResult.exitCode,
|
|
289
|
-
signal: freshResult.signal,
|
|
290
|
-
backgroundId: freshResult.backgroundId,
|
|
291
|
-
},
|
|
415
|
+
content: [{ type: "text", text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}` }],
|
|
416
|
+
details: { sessionId, status: freshStatus, runtime: freshRuntime, output: freshOutput.output, outputTruncated: freshOutput.truncated, outputTotalBytes: freshOutput.totalBytes, outputTotalLines: freshOutput.totalLines, hasMore: freshOutput.hasMore, exitCode: freshResult.exitCode, signal: freshResult.signal, backgroundId: freshResult.backgroundId },
|
|
292
417
|
};
|
|
293
418
|
}
|
|
294
|
-
|
|
295
419
|
return {
|
|
296
|
-
content: [
|
|
297
|
-
|
|
298
|
-
type: "text",
|
|
299
|
-
text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}`,
|
|
300
|
-
},
|
|
301
|
-
],
|
|
302
|
-
details: {
|
|
303
|
-
sessionId,
|
|
304
|
-
status: freshStatus,
|
|
305
|
-
runtime: freshRuntime,
|
|
306
|
-
output: freshOutput.output,
|
|
307
|
-
outputTruncated: freshOutput.truncated,
|
|
308
|
-
outputTotalBytes: freshOutput.totalBytes,
|
|
309
|
-
outputTotalLines: freshOutput.totalLines,
|
|
310
|
-
hasMore: freshOutput.hasMore,
|
|
311
|
-
hasOutput,
|
|
312
|
-
},
|
|
420
|
+
content: [{ type: "text", text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}` }],
|
|
421
|
+
details: { sessionId, status: freshStatus, runtime: freshRuntime, output: freshOutput.output, outputTruncated: freshOutput.truncated, outputTotalBytes: freshOutput.totalBytes, outputTotalLines: freshOutput.totalLines, hasMore: freshOutput.hasMore, hasOutput },
|
|
313
422
|
};
|
|
314
423
|
}
|
|
315
424
|
|
|
316
425
|
const { output, truncated, totalBytes, totalLines, hasMore } = outputResult;
|
|
317
|
-
|
|
318
426
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
319
427
|
const hasOutput = output.length > 0;
|
|
320
|
-
// Only show "(more available)" when there's more to read; absence means caught up
|
|
321
428
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
322
|
-
|
|
323
429
|
return {
|
|
324
|
-
content: [
|
|
325
|
-
|
|
326
|
-
type: "text",
|
|
327
|
-
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}`,
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
details: {
|
|
331
|
-
sessionId,
|
|
332
|
-
status,
|
|
333
|
-
runtime,
|
|
334
|
-
output,
|
|
335
|
-
outputTruncated: truncated,
|
|
336
|
-
outputTotalBytes: totalBytes,
|
|
337
|
-
outputTotalLines: totalLines,
|
|
338
|
-
hasMore,
|
|
339
|
-
hasOutput,
|
|
340
|
-
},
|
|
430
|
+
content: [{ type: "text", text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
431
|
+
details: { sessionId, status, runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, hasOutput },
|
|
341
432
|
};
|
|
342
433
|
}
|
|
343
434
|
|
|
@@ -347,17 +438,221 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
347
438
|
};
|
|
348
439
|
}
|
|
349
440
|
|
|
350
|
-
//
|
|
441
|
+
// ── Branch 2: Attach to background session ──
|
|
442
|
+
if (attach) {
|
|
443
|
+
if (background) {
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: "text", text: "Cannot attach and background simultaneously." }],
|
|
446
|
+
isError: true,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (!ctx.hasUI) {
|
|
450
|
+
return {
|
|
451
|
+
content: [{ type: "text", text: "Attach requires interactive TUI mode" }],
|
|
452
|
+
isError: true,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (overlayOpen) {
|
|
456
|
+
return {
|
|
457
|
+
content: [{ type: "text", text: "An interactive shell overlay is already open." }],
|
|
458
|
+
isError: true,
|
|
459
|
+
details: { error: "overlay_already_open" },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const bgSession = sessionManager.take(attach);
|
|
464
|
+
if (!bgSession) {
|
|
465
|
+
return {
|
|
466
|
+
content: [{ type: "text", text: `Background session not found: ${attach}` }],
|
|
467
|
+
isError: true,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const config = loadConfig(cwd ?? ctx.cwd);
|
|
472
|
+
const reattachSessionId = attach;
|
|
473
|
+
const monitor = headlessMonitors.get(attach);
|
|
474
|
+
|
|
475
|
+
const isNonBlocking = mode === "hands-free" || mode === "dispatch";
|
|
476
|
+
|
|
477
|
+
overlayOpen = true;
|
|
478
|
+
const attachStartTime = Date.now();
|
|
479
|
+
const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
|
|
480
|
+
(tui, theme, _kb, done) =>
|
|
481
|
+
new InteractiveShellOverlay(tui, theme, {
|
|
482
|
+
command: bgSession.command,
|
|
483
|
+
existingSession: bgSession.session,
|
|
484
|
+
sessionId: reattachSessionId,
|
|
485
|
+
mode,
|
|
486
|
+
cwd: cwd ?? ctx.cwd,
|
|
487
|
+
name: bgSession.name,
|
|
488
|
+
reason: bgSession.reason ?? reason,
|
|
489
|
+
handsFreeUpdateMode: handsFree?.updateMode,
|
|
490
|
+
handsFreeUpdateInterval: handsFree?.updateInterval,
|
|
491
|
+
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
492
|
+
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
493
|
+
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
494
|
+
autoExitOnQuiet: mode === "dispatch"
|
|
495
|
+
? handsFree?.autoExitOnQuiet !== false
|
|
496
|
+
: handsFree?.autoExitOnQuiet === true,
|
|
497
|
+
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
498
|
+
handoffPreviewLines: handoffPreview?.lines,
|
|
499
|
+
handoffPreviewMaxChars: handoffPreview?.maxChars,
|
|
500
|
+
handoffSnapshotEnabled: handoffSnapshot?.enabled,
|
|
501
|
+
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
502
|
+
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
503
|
+
timeout,
|
|
504
|
+
}, config, done),
|
|
505
|
+
{
|
|
506
|
+
overlay: true,
|
|
507
|
+
overlayOptions: {
|
|
508
|
+
width: `${config.overlayWidthPercent}%`,
|
|
509
|
+
maxHeight: `${config.overlayHeightPercent}%`,
|
|
510
|
+
anchor: "center",
|
|
511
|
+
margin: 1,
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (isNonBlocking) {
|
|
517
|
+
setupDispatchCompletion(pi, overlayPromise, config, {
|
|
518
|
+
id: reattachSessionId,
|
|
519
|
+
mode: mode!,
|
|
520
|
+
command: bgSession.command,
|
|
521
|
+
reason: bgSession.reason,
|
|
522
|
+
timeout,
|
|
523
|
+
handsFree,
|
|
524
|
+
overlayStartTime: attachStartTime,
|
|
525
|
+
});
|
|
526
|
+
return {
|
|
527
|
+
content: [{ type: "text", text: mode === "dispatch"
|
|
528
|
+
? `Reattached to ${reattachSessionId}. You'll be notified when it completes.`
|
|
529
|
+
: `Reattached to ${reattachSessionId}.\nUse interactive_shell({ sessionId: "${reattachSessionId}" }) to check status/output.` }],
|
|
530
|
+
details: { sessionId: reattachSessionId, status: "running", command: bgSession.command, reason: bgSession.reason, mode },
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Blocking (interactive) attach
|
|
535
|
+
let result: InteractiveShellResult;
|
|
536
|
+
try {
|
|
537
|
+
result = await overlayPromise;
|
|
538
|
+
} finally {
|
|
539
|
+
overlayOpen = false;
|
|
540
|
+
}
|
|
541
|
+
if (monitor) {
|
|
542
|
+
monitor.dispose();
|
|
543
|
+
headlessMonitors.delete(attach);
|
|
544
|
+
sessionManager.unregisterActive(attach, !result.backgrounded);
|
|
545
|
+
} else if (!result.backgrounded) {
|
|
546
|
+
releaseSessionId(attach);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let summary: string;
|
|
550
|
+
if (result.transferred) {
|
|
551
|
+
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
|
|
552
|
+
summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
|
|
553
|
+
} else if (result.backgrounded) {
|
|
554
|
+
summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
|
|
555
|
+
} else if (result.cancelled) {
|
|
556
|
+
summary = "Session killed";
|
|
557
|
+
} else if (result.timedOut) {
|
|
558
|
+
summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
|
|
559
|
+
} else {
|
|
560
|
+
const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
|
|
561
|
+
summary = `Session ended ${status}`;
|
|
562
|
+
}
|
|
563
|
+
if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
|
|
564
|
+
summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
|
|
565
|
+
}
|
|
566
|
+
return { content: [{ type: "text", text: summary }], details: result };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Branch 3: List background sessions ──
|
|
570
|
+
if (listBackground) {
|
|
571
|
+
const sessions = sessionManager.list();
|
|
572
|
+
if (sessions.length === 0) {
|
|
573
|
+
return { content: [{ type: "text", text: "No background sessions." }] };
|
|
574
|
+
}
|
|
575
|
+
const lines = sessions.map(s => {
|
|
576
|
+
const status = s.session.exited ? "exited" : "running";
|
|
577
|
+
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
578
|
+
const r = s.reason ? ` \u2022 ${s.reason}` : "";
|
|
579
|
+
return ` ${s.id} - ${s.command}${r} (${status}, ${duration})`;
|
|
580
|
+
});
|
|
581
|
+
return { content: [{ type: "text", text: `Background sessions:\n${lines.join("\n")}` }] };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── Branch 3b: Dismiss background sessions ──
|
|
585
|
+
if (dismissBackground) {
|
|
586
|
+
if (typeof dismissBackground === "string") {
|
|
587
|
+
if (!sessionManager.list().some(s => s.id === dismissBackground)) {
|
|
588
|
+
return { content: [{ type: "text", text: `Background session not found: ${dismissBackground}` }], isError: true };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const targetIds = typeof dismissBackground === "string"
|
|
593
|
+
? [dismissBackground]
|
|
594
|
+
: sessionManager.list().map(s => s.id);
|
|
595
|
+
|
|
596
|
+
if (targetIds.length === 0) {
|
|
597
|
+
return { content: [{ type: "text", text: "No background sessions to dismiss." }] };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
for (const tid of targetIds) {
|
|
601
|
+
const monitor = headlessMonitors.get(tid);
|
|
602
|
+
if (monitor) {
|
|
603
|
+
monitor.dispose();
|
|
604
|
+
headlessMonitors.delete(tid);
|
|
605
|
+
}
|
|
606
|
+
sessionManager.unregisterActive(tid, false);
|
|
607
|
+
sessionManager.remove(tid);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const summary = targetIds.length === 1
|
|
611
|
+
? `Dismissed session ${targetIds[0]}.`
|
|
612
|
+
: `Dismissed ${targetIds.length} sessions: ${targetIds.join(", ")}.`;
|
|
613
|
+
return { content: [{ type: "text", text: summary }] };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── Branch 4: Start new session ──
|
|
351
617
|
if (!command) {
|
|
352
618
|
return {
|
|
353
|
-
content: [
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
619
|
+
content: [{ type: "text", text: "One of 'command', 'sessionId', 'attach', 'listBackground', or 'dismissBackground' is required." }],
|
|
620
|
+
isError: true,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const effectiveCwd = cwd ?? ctx.cwd;
|
|
625
|
+
const config = loadConfig(effectiveCwd);
|
|
626
|
+
const isNonBlocking = mode === "hands-free" || mode === "dispatch";
|
|
627
|
+
|
|
628
|
+
// ── Branch 4a: Headless dispatch ──
|
|
629
|
+
if (mode === "dispatch" && background) {
|
|
630
|
+
const id = generateSessionId(name);
|
|
631
|
+
const session = new PtyTerminalSession(
|
|
632
|
+
{ command, cwd: effectiveCwd, cols: 120, rows: 40, scrollback: config.scrollbackLines },
|
|
633
|
+
);
|
|
634
|
+
sessionManager.add(command, session, name, reason, { id, noAutoCleanup: true });
|
|
635
|
+
|
|
636
|
+
const startTime = Date.now();
|
|
637
|
+
const monitor = new HeadlessDispatchMonitor(session, config, {
|
|
638
|
+
autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
|
|
639
|
+
quietThreshold: handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
|
|
640
|
+
timeout,
|
|
641
|
+
}, makeMonitorCompletionCallback(pi, id, startTime));
|
|
642
|
+
headlessMonitors.set(id, monitor);
|
|
643
|
+
registerHeadlessActive(id, command, reason, session, monitor, startTime);
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
content: [{ type: "text", text: `Session dispatched in background (id: ${id}).\nYou'll be notified when it completes. User can /attach ${id} to watch.` }],
|
|
647
|
+
details: { sessionId: id, backgroundId: id, mode: "dispatch", background: true },
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Validate: background only valid with dispatch for new sessions
|
|
652
|
+
if (background) {
|
|
653
|
+
return {
|
|
654
|
+
content: [{ type: "text", text: "background: true requires mode='dispatch' for new sessions." }],
|
|
359
655
|
isError: true,
|
|
360
|
-
details: {},
|
|
361
656
|
};
|
|
362
657
|
}
|
|
363
658
|
|
|
@@ -365,15 +660,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
365
660
|
return {
|
|
366
661
|
content: [{ type: "text", text: "Interactive shell requires interactive TUI mode" }],
|
|
367
662
|
isError: true,
|
|
368
|
-
details: {},
|
|
369
663
|
};
|
|
370
664
|
}
|
|
371
665
|
|
|
372
|
-
const effectiveCwd = cwd ?? ctx.cwd;
|
|
373
|
-
const config = loadConfig(effectiveCwd);
|
|
374
|
-
const isHandsFree = mode === "hands-free";
|
|
375
|
-
|
|
376
|
-
// Prevent starting a new overlay while one is already open
|
|
377
666
|
if (overlayOpen) {
|
|
378
667
|
return {
|
|
379
668
|
content: [{ type: "text", text: "An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one." }],
|
|
@@ -382,47 +671,38 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
382
671
|
};
|
|
383
672
|
}
|
|
384
673
|
|
|
385
|
-
|
|
386
|
-
const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
|
|
674
|
+
const generatedSessionId = isNonBlocking ? generateSessionId(name) : undefined;
|
|
387
675
|
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
if (isHandsFree && generatedSessionId) {
|
|
391
|
-
// Mark overlay as open
|
|
676
|
+
// ── Non-blocking path (hands-free or dispatch) ──
|
|
677
|
+
if (isNonBlocking && generatedSessionId) {
|
|
392
678
|
overlayOpen = true;
|
|
679
|
+
const overlayStartTime = Date.now();
|
|
393
680
|
|
|
394
|
-
// Start overlay but don't await - it runs in background
|
|
395
681
|
const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
|
|
396
682
|
(tui, theme, _kb, done) =>
|
|
397
|
-
new InteractiveShellOverlay(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
421
|
-
timeout,
|
|
422
|
-
},
|
|
423
|
-
config,
|
|
424
|
-
done,
|
|
425
|
-
),
|
|
683
|
+
new InteractiveShellOverlay(tui, theme, {
|
|
684
|
+
command,
|
|
685
|
+
cwd: effectiveCwd,
|
|
686
|
+
name,
|
|
687
|
+
reason,
|
|
688
|
+
mode,
|
|
689
|
+
sessionId: generatedSessionId,
|
|
690
|
+
handsFreeUpdateMode: handsFree?.updateMode,
|
|
691
|
+
handsFreeUpdateInterval: handsFree?.updateInterval,
|
|
692
|
+
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
693
|
+
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
694
|
+
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
695
|
+
autoExitOnQuiet: mode === "dispatch"
|
|
696
|
+
? handsFree?.autoExitOnQuiet !== false
|
|
697
|
+
: handsFree?.autoExitOnQuiet === true,
|
|
698
|
+
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
699
|
+
handoffPreviewLines: handoffPreview?.lines,
|
|
700
|
+
handoffPreviewMaxChars: handoffPreview?.maxChars,
|
|
701
|
+
handoffSnapshotEnabled: handoffSnapshot?.enabled,
|
|
702
|
+
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
703
|
+
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
704
|
+
timeout,
|
|
705
|
+
}, config, done),
|
|
426
706
|
{
|
|
427
707
|
overlay: true,
|
|
428
708
|
overlayOptions: {
|
|
@@ -434,82 +714,40 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
434
714
|
},
|
|
435
715
|
);
|
|
436
716
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
: "";
|
|
446
|
-
const content = `Session ${generatedSessionId} output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
|
|
447
|
-
|
|
448
|
-
// Send message with triggerTurn to wake the agent
|
|
449
|
-
pi.sendMessage({
|
|
450
|
-
customType: "interactive-shell-transfer",
|
|
451
|
-
content,
|
|
452
|
-
display: true,
|
|
453
|
-
details: {
|
|
454
|
-
sessionId: generatedSessionId,
|
|
455
|
-
transferred: result.transferred,
|
|
456
|
-
exitCode: result.exitCode,
|
|
457
|
-
signal: result.signal,
|
|
458
|
-
},
|
|
459
|
-
}, { triggerTurn: true });
|
|
460
|
-
|
|
461
|
-
// Emit event for extensions that want to handle transfers
|
|
462
|
-
pi.events.emit("interactive-shell:transfer", {
|
|
463
|
-
sessionId: generatedSessionId,
|
|
464
|
-
transferred: result.transferred,
|
|
465
|
-
exitCode: result.exitCode,
|
|
466
|
-
signal: result.signal,
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
// Unregister session - PTY is disposed, agent has the output via sendMessage
|
|
470
|
-
sessionManager.unregisterActive(generatedSessionId, true);
|
|
471
|
-
}
|
|
472
|
-
}).catch(() => {
|
|
473
|
-
overlayOpen = false;
|
|
474
|
-
// Ignore errors - session cleanup handles this
|
|
717
|
+
setupDispatchCompletion(pi, overlayPromise, config, {
|
|
718
|
+
id: generatedSessionId,
|
|
719
|
+
mode: mode!,
|
|
720
|
+
command,
|
|
721
|
+
reason,
|
|
722
|
+
timeout,
|
|
723
|
+
handsFree,
|
|
724
|
+
overlayStartTime,
|
|
475
725
|
});
|
|
476
726
|
|
|
477
|
-
|
|
727
|
+
if (mode === "dispatch") {
|
|
728
|
+
return {
|
|
729
|
+
content: [{ type: "text", text: `Session dispatched (id: ${generatedSessionId}).\nYou'll be notified when it completes.\nYou can still query with interactive_shell({ sessionId: "${generatedSessionId}" }) if needed.` }],
|
|
730
|
+
details: { sessionId: generatedSessionId, status: "running", command, reason, mode },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
478
733
|
return {
|
|
479
|
-
content: [
|
|
480
|
-
|
|
481
|
-
type: "text",
|
|
482
|
-
text: `Session started: ${generatedSessionId}\nCommand: ${command}\n\nUse interactive_shell({ sessionId: "${generatedSessionId}" }) to check status/output.\nUse interactive_shell({ sessionId: "${generatedSessionId}", kill: true }) to end when done.`,
|
|
483
|
-
},
|
|
484
|
-
],
|
|
485
|
-
details: {
|
|
486
|
-
sessionId: generatedSessionId,
|
|
487
|
-
status: "running",
|
|
488
|
-
command,
|
|
489
|
-
reason,
|
|
490
|
-
},
|
|
734
|
+
content: [{ type: "text", text: `Session started: ${generatedSessionId}\nCommand: ${command}\n\nUse interactive_shell({ sessionId: "${generatedSessionId}" }) to check status/output.\nUse interactive_shell({ sessionId: "${generatedSessionId}", kill: true }) to end when done.` }],
|
|
735
|
+
details: { sessionId: generatedSessionId, status: "running", command, reason },
|
|
491
736
|
};
|
|
492
737
|
}
|
|
493
738
|
|
|
494
|
-
//
|
|
739
|
+
// ── Blocking (interactive) path ──
|
|
495
740
|
overlayOpen = true;
|
|
496
741
|
onUpdate?.({
|
|
497
742
|
content: [{ type: "text", text: `Opening: ${command}` }],
|
|
498
|
-
details: {
|
|
499
|
-
exitCode: null,
|
|
500
|
-
backgrounded: false,
|
|
501
|
-
cancelled: false,
|
|
502
|
-
},
|
|
743
|
+
details: { exitCode: null, backgrounded: false, cancelled: false },
|
|
503
744
|
});
|
|
504
745
|
|
|
505
746
|
let result: InteractiveShellResult;
|
|
506
747
|
try {
|
|
507
748
|
result = await ctx.ui.custom<InteractiveShellResult>(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
tui,
|
|
511
|
-
theme,
|
|
512
|
-
{
|
|
749
|
+
(tui, theme, _kb, done) =>
|
|
750
|
+
new InteractiveShellOverlay(tui, theme, {
|
|
513
751
|
command,
|
|
514
752
|
cwd: effectiveCwd,
|
|
515
753
|
name,
|
|
@@ -522,41 +760,37 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
522
760
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
523
761
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
524
762
|
autoExitOnQuiet: handsFree?.autoExitOnQuiet,
|
|
525
|
-
onHandsFreeUpdate:
|
|
763
|
+
onHandsFreeUpdate: mode === "hands-free"
|
|
526
764
|
? (update) => {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
: "";
|
|
539
|
-
statusText = `Session ${update.sessionId} running (${formatDurationMs(update.runtime)})${budgetInfo}`;
|
|
540
|
-
}
|
|
765
|
+
let statusText: string;
|
|
766
|
+
switch (update.status) {
|
|
767
|
+
case "user-takeover":
|
|
768
|
+
statusText = `User took over session ${update.sessionId}`;
|
|
769
|
+
break;
|
|
770
|
+
case "exited":
|
|
771
|
+
statusText = `Session ${update.sessionId} exited`;
|
|
772
|
+
break;
|
|
773
|
+
default: {
|
|
774
|
+
const budgetInfo = update.budgetExhausted ? " [budget exhausted]" : "";
|
|
775
|
+
statusText = `Session ${update.sessionId} running (${formatDurationMs(update.runtime)})${budgetInfo}`;
|
|
541
776
|
}
|
|
542
|
-
// Only include new output if there is any
|
|
543
|
-
const newOutput =
|
|
544
|
-
update.status === "running" && update.tail.length > 0
|
|
545
|
-
? `\n\n${update.tail.join("\n")}`
|
|
546
|
-
: "";
|
|
547
|
-
onUpdate?.({
|
|
548
|
-
content: [{ type: "text", text: statusText + newOutput }],
|
|
549
|
-
details: {
|
|
550
|
-
status: update.status,
|
|
551
|
-
sessionId: update.sessionId,
|
|
552
|
-
runtime: update.runtime,
|
|
553
|
-
newChars: update.tail.join("\n").length,
|
|
554
|
-
totalCharsSent: update.totalCharsSent,
|
|
555
|
-
budgetExhausted: update.budgetExhausted,
|
|
556
|
-
userTookOver: update.userTookOver,
|
|
557
|
-
},
|
|
558
|
-
});
|
|
559
777
|
}
|
|
778
|
+
const newOutput = update.status === "running" && update.tail.length > 0
|
|
779
|
+
? `\n\n${update.tail.join("\n")}`
|
|
780
|
+
: "";
|
|
781
|
+
onUpdate?.({
|
|
782
|
+
content: [{ type: "text", text: statusText + newOutput }],
|
|
783
|
+
details: {
|
|
784
|
+
status: update.status,
|
|
785
|
+
sessionId: update.sessionId,
|
|
786
|
+
runtime: update.runtime,
|
|
787
|
+
newChars: update.tail.join("\n").length,
|
|
788
|
+
totalCharsSent: update.totalCharsSent,
|
|
789
|
+
budgetExhausted: update.budgetExhausted,
|
|
790
|
+
userTookOver: update.userTookOver,
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
}
|
|
560
794
|
: undefined,
|
|
561
795
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
562
796
|
handoffPreviewLines: handoffPreview?.lines,
|
|
@@ -565,30 +799,24 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
565
799
|
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
566
800
|
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
567
801
|
timeout,
|
|
802
|
+
}, config, done),
|
|
803
|
+
{
|
|
804
|
+
overlay: true,
|
|
805
|
+
overlayOptions: {
|
|
806
|
+
width: `${config.overlayWidthPercent}%`,
|
|
807
|
+
maxHeight: `${config.overlayHeightPercent}%`,
|
|
808
|
+
anchor: "center",
|
|
809
|
+
margin: 1,
|
|
568
810
|
},
|
|
569
|
-
config,
|
|
570
|
-
done,
|
|
571
|
-
),
|
|
572
|
-
{
|
|
573
|
-
overlay: true,
|
|
574
|
-
overlayOptions: {
|
|
575
|
-
width: `${config.overlayWidthPercent}%`,
|
|
576
|
-
maxHeight: `${config.overlayHeightPercent}%`,
|
|
577
|
-
anchor: "center",
|
|
578
|
-
margin: 1,
|
|
579
811
|
},
|
|
580
|
-
|
|
581
|
-
);
|
|
812
|
+
);
|
|
582
813
|
} finally {
|
|
583
814
|
overlayOpen = false;
|
|
584
815
|
}
|
|
585
816
|
|
|
586
817
|
let summary: string;
|
|
587
818
|
if (result.transferred) {
|
|
588
|
-
|
|
589
|
-
const truncatedNote = result.transferred.truncated
|
|
590
|
-
? ` (truncated from ${result.transferred.totalLines} total lines)`
|
|
591
|
-
: "";
|
|
819
|
+
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
|
|
592
820
|
summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
|
|
593
821
|
} else if (result.backgrounded) {
|
|
594
822
|
summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
|
|
@@ -610,53 +838,45 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
610
838
|
summary += `\n\n${warning}`;
|
|
611
839
|
}
|
|
612
840
|
|
|
613
|
-
// Only include handoff preview if not already transferring (transfer includes full output)
|
|
614
841
|
if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
|
|
615
|
-
|
|
616
|
-
summary += tailHeader + result.handoffPreview.lines.join("\n");
|
|
842
|
+
summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
|
|
617
843
|
}
|
|
618
844
|
|
|
619
|
-
return {
|
|
620
|
-
content: [{ type: "text", text: summary }],
|
|
621
|
-
details: result,
|
|
622
|
-
};
|
|
845
|
+
return { content: [{ type: "text", text: summary }], details: result };
|
|
623
846
|
},
|
|
624
847
|
});
|
|
625
848
|
|
|
626
849
|
pi.registerCommand("attach", {
|
|
627
850
|
description: "Reattach to a background shell session",
|
|
628
851
|
handler: async (args, ctx) => {
|
|
629
|
-
// Prevent reattaching while another overlay is open
|
|
630
852
|
if (overlayOpen) {
|
|
631
853
|
ctx.ui.notify("An overlay is already open. Close it first.", "error");
|
|
632
854
|
return;
|
|
633
855
|
}
|
|
634
856
|
|
|
635
857
|
const sessions = sessionManager.list();
|
|
636
|
-
|
|
637
858
|
if (sessions.length === 0) {
|
|
638
859
|
ctx.ui.notify("No background sessions", "info");
|
|
639
860
|
return;
|
|
640
861
|
}
|
|
641
862
|
|
|
642
863
|
let targetId = args.trim();
|
|
643
|
-
|
|
644
864
|
if (!targetId) {
|
|
645
865
|
const options = sessions.map((s) => {
|
|
646
866
|
const status = s.session.exited ? "exited" : "running";
|
|
647
867
|
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
648
|
-
// Sanitize command and reason: collapse newlines and whitespace for display
|
|
649
868
|
const sanitizedCommand = s.command.replace(/\s+/g, " ").trim();
|
|
650
869
|
const sanitizedReason = s.reason?.replace(/\s+/g, " ").trim();
|
|
651
|
-
const
|
|
652
|
-
return `${s.id} - ${sanitizedCommand}${
|
|
870
|
+
const r = sanitizedReason ? ` \u2022 ${sanitizedReason}` : "";
|
|
871
|
+
return `${s.id} - ${sanitizedCommand}${r} (${status}, ${duration})`;
|
|
653
872
|
});
|
|
654
|
-
|
|
655
873
|
const choice = await ctx.ui.select("Background Sessions", options);
|
|
656
874
|
if (!choice) return;
|
|
657
875
|
targetId = choice.split(" - ")[0]!;
|
|
658
876
|
}
|
|
659
877
|
|
|
878
|
+
const monitor = headlessMonitors.get(targetId);
|
|
879
|
+
|
|
660
880
|
const session = sessionManager.get(targetId);
|
|
661
881
|
if (!session) {
|
|
662
882
|
ctx.ui.notify(`Session not found: ${targetId}`, "error");
|
|
@@ -666,15 +886,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
666
886
|
const config = loadConfig(ctx.cwd);
|
|
667
887
|
overlayOpen = true;
|
|
668
888
|
try {
|
|
669
|
-
await ctx.ui.custom<InteractiveShellResult>(
|
|
889
|
+
const result = await ctx.ui.custom<InteractiveShellResult>(
|
|
670
890
|
(tui, theme, _kb, done) =>
|
|
671
|
-
new ReattachOverlay(
|
|
672
|
-
tui,
|
|
673
|
-
theme,
|
|
674
|
-
{ id: session.id, command: session.command, reason: session.reason, session: session.session },
|
|
675
|
-
config,
|
|
676
|
-
done,
|
|
677
|
-
),
|
|
891
|
+
new ReattachOverlay(tui, theme, { id: session.id, command: session.command, reason: session.reason, session: session.session }, config, done),
|
|
678
892
|
{
|
|
679
893
|
overlay: true,
|
|
680
894
|
overlayOptions: {
|
|
@@ -685,11 +899,177 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
685
899
|
},
|
|
686
900
|
},
|
|
687
901
|
);
|
|
902
|
+
|
|
903
|
+
if (monitor && !monitor.disposed) {
|
|
904
|
+
if (!result.backgrounded) {
|
|
905
|
+
monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
|
|
906
|
+
headlessMonitors.delete(targetId);
|
|
907
|
+
}
|
|
908
|
+
} else if (result.backgrounded) {
|
|
909
|
+
sessionManager.restartAutoCleanup(targetId);
|
|
910
|
+
} else {
|
|
911
|
+
sessionManager.scheduleCleanup(targetId);
|
|
912
|
+
}
|
|
688
913
|
} finally {
|
|
689
914
|
overlayOpen = false;
|
|
690
915
|
}
|
|
691
916
|
},
|
|
692
917
|
});
|
|
918
|
+
|
|
919
|
+
pi.registerCommand("dismiss", {
|
|
920
|
+
description: "Dismiss background shell sessions (kill running, remove exited)",
|
|
921
|
+
handler: async (args, ctx) => {
|
|
922
|
+
const sessions = sessionManager.list();
|
|
923
|
+
if (sessions.length === 0) {
|
|
924
|
+
ctx.ui.notify("No background sessions", "info");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
let targetIds: string[];
|
|
929
|
+
const arg = args.trim();
|
|
930
|
+
if (arg) {
|
|
931
|
+
if (!sessions.some(s => s.id === arg)) {
|
|
932
|
+
ctx.ui.notify(`Session not found: ${arg}`, "error");
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
targetIds = [arg];
|
|
936
|
+
} else if (sessions.length === 1) {
|
|
937
|
+
targetIds = [sessions[0].id];
|
|
938
|
+
} else {
|
|
939
|
+
const options = ["All sessions", ...sessions.map((s) => {
|
|
940
|
+
const status = s.session.exited ? "exited" : "running";
|
|
941
|
+
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
942
|
+
return `${s.id} (${status}, ${duration})`;
|
|
943
|
+
})];
|
|
944
|
+
const choice = await ctx.ui.select("Dismiss sessions", options);
|
|
945
|
+
if (!choice) return;
|
|
946
|
+
targetIds = choice === "All sessions"
|
|
947
|
+
? sessions.map(s => s.id)
|
|
948
|
+
: [choice.split(" (")[0]];
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
for (const tid of targetIds) {
|
|
952
|
+
const monitor = headlessMonitors.get(tid);
|
|
953
|
+
if (monitor) {
|
|
954
|
+
monitor.dispose();
|
|
955
|
+
headlessMonitors.delete(tid);
|
|
956
|
+
}
|
|
957
|
+
sessionManager.unregisterActive(tid, false);
|
|
958
|
+
sessionManager.remove(tid);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const noun = targetIds.length === 1 ? "session" : "sessions";
|
|
962
|
+
ctx.ui.notify(`Dismissed ${targetIds.length} ${noun}`, "info");
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function setupDispatchCompletion(
|
|
968
|
+
pi: ExtensionAPI,
|
|
969
|
+
overlayPromise: Promise<InteractiveShellResult>,
|
|
970
|
+
config: InteractiveShellConfig,
|
|
971
|
+
ctx: {
|
|
972
|
+
id: string;
|
|
973
|
+
mode: string;
|
|
974
|
+
command: string;
|
|
975
|
+
reason?: string;
|
|
976
|
+
timeout?: number;
|
|
977
|
+
handsFree?: { autoExitOnQuiet?: boolean; quietThreshold?: number };
|
|
978
|
+
overlayStartTime?: number;
|
|
979
|
+
},
|
|
980
|
+
): void {
|
|
981
|
+
const { id, mode, command, reason } = ctx;
|
|
982
|
+
|
|
983
|
+
overlayPromise.then((result) => {
|
|
984
|
+
overlayOpen = false;
|
|
985
|
+
|
|
986
|
+
const wasAgentInitiated = agentHandledCompletion;
|
|
987
|
+
agentHandledCompletion = false;
|
|
988
|
+
|
|
989
|
+
if (result.transferred) {
|
|
990
|
+
const truncatedNote = result.transferred.truncated
|
|
991
|
+
? ` (truncated from ${result.transferred.totalLines} total lines)`
|
|
992
|
+
: "";
|
|
993
|
+
const content = `Session ${id} output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
|
|
994
|
+
pi.sendMessage({
|
|
995
|
+
customType: "interactive-shell-transfer",
|
|
996
|
+
content,
|
|
997
|
+
display: true,
|
|
998
|
+
details: { sessionId: id, transferred: result.transferred, exitCode: result.exitCode, signal: result.signal },
|
|
999
|
+
}, { triggerTurn: true });
|
|
1000
|
+
pi.events.emit("interactive-shell:transfer", { sessionId: id, transferred: result.transferred, exitCode: result.exitCode, signal: result.signal });
|
|
1001
|
+
sessionManager.unregisterActive(id, true);
|
|
1002
|
+
|
|
1003
|
+
const remainingMonitor = headlessMonitors.get(id);
|
|
1004
|
+
if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
|
|
1005
|
+
} else if (mode === "dispatch" && result.backgrounded) {
|
|
1006
|
+
if (!wasAgentInitiated) {
|
|
1007
|
+
pi.sendMessage({
|
|
1008
|
+
customType: "interactive-shell-transfer",
|
|
1009
|
+
content: `Session ${id} moved to background (id: ${result.backgroundId}).`,
|
|
1010
|
+
display: true,
|
|
1011
|
+
details: { sessionId: id, backgroundId: result.backgroundId },
|
|
1012
|
+
}, { triggerTurn: true });
|
|
1013
|
+
}
|
|
1014
|
+
sessionManager.unregisterActive(id, false);
|
|
1015
|
+
|
|
1016
|
+
const existingMonitor = headlessMonitors.get(id);
|
|
1017
|
+
if (existingMonitor && !existingMonitor.disposed) {
|
|
1018
|
+
const bgSession = sessionManager.get(result.backgroundId!);
|
|
1019
|
+
if (bgSession) {
|
|
1020
|
+
registerHeadlessActive(result.backgroundId!, command, reason, bgSession.session, existingMonitor, existingMonitor.startTime);
|
|
1021
|
+
}
|
|
1022
|
+
} else if (!existingMonitor) {
|
|
1023
|
+
const bgSession = sessionManager.get(result.backgroundId!);
|
|
1024
|
+
if (bgSession) {
|
|
1025
|
+
const bgId = result.backgroundId!;
|
|
1026
|
+
const bgStartTime = ctx.overlayStartTime ?? Date.now();
|
|
1027
|
+
const elapsed = ctx.overlayStartTime ? Date.now() - ctx.overlayStartTime : 0;
|
|
1028
|
+
const remainingTimeout = ctx.timeout ? Math.max(0, ctx.timeout - elapsed) : undefined;
|
|
1029
|
+
|
|
1030
|
+
const monitor = new HeadlessDispatchMonitor(bgSession.session, config, {
|
|
1031
|
+
autoExitOnQuiet: ctx.handsFree?.autoExitOnQuiet !== false,
|
|
1032
|
+
quietThreshold: ctx.handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
|
|
1033
|
+
timeout: remainingTimeout,
|
|
1034
|
+
}, makeMonitorCompletionCallback(pi, bgId, bgStartTime));
|
|
1035
|
+
headlessMonitors.set(bgId, monitor);
|
|
1036
|
+
registerHeadlessActive(bgId, command, reason, bgSession.session, monitor, bgStartTime);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
} else if (mode === "dispatch") {
|
|
1040
|
+
if (!wasAgentInitiated) {
|
|
1041
|
+
const content = buildResultNotification(id, result);
|
|
1042
|
+
pi.sendMessage({
|
|
1043
|
+
customType: "interactive-shell-transfer",
|
|
1044
|
+
content,
|
|
1045
|
+
display: true,
|
|
1046
|
+
details: { sessionId: id, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, cancelled: result.cancelled, completionOutput: result.completionOutput },
|
|
1047
|
+
}, { triggerTurn: true });
|
|
1048
|
+
}
|
|
1049
|
+
pi.events.emit("interactive-shell:transfer", {
|
|
1050
|
+
sessionId: id,
|
|
1051
|
+
completionOutput: result.completionOutput,
|
|
1052
|
+
exitCode: result.exitCode,
|
|
1053
|
+
signal: result.signal,
|
|
1054
|
+
timedOut: result.timedOut,
|
|
1055
|
+
cancelled: result.cancelled,
|
|
1056
|
+
});
|
|
1057
|
+
sessionManager.unregisterActive(id, true);
|
|
1058
|
+
|
|
1059
|
+
const remainingMonitor = headlessMonitors.get(id);
|
|
1060
|
+
if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (mode !== "dispatch") {
|
|
1064
|
+
const staleMonitor = headlessMonitors.get(id);
|
|
1065
|
+
if (staleMonitor) { staleMonitor.dispose(); headlessMonitors.delete(id); }
|
|
1066
|
+
}
|
|
1067
|
+
}).catch(() => {
|
|
1068
|
+
overlayOpen = false;
|
|
1069
|
+
sessionManager.unregisterActive(id, true);
|
|
1070
|
+
const orphanedMonitor = headlessMonitors.get(id);
|
|
1071
|
+
if (orphanedMonitor) { orphanedMonitor.dispose(); headlessMonitors.delete(id); }
|
|
1072
|
+
});
|
|
693
1073
|
}
|
|
694
1074
|
|
|
695
1075
|
function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
|
|
@@ -703,8 +1083,6 @@ function buildIdlePromptWarning(command: string, reason: string | undefined): st
|
|
|
703
1083
|
const bin = binaries.find((b) => trimmed === b || trimmed.startsWith(`${b} `));
|
|
704
1084
|
if (!bin) return null;
|
|
705
1085
|
|
|
706
|
-
// Consider "idle" when the command has no obvious positional prompt and only contains flags.
|
|
707
|
-
// This is intentionally conservative to avoid false positives.
|
|
708
1086
|
const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
|
|
709
1087
|
const hasQuotedPrompt = /["']/.test(rest);
|
|
710
1088
|
const hasKnownPromptFlag =
|
|
@@ -716,7 +1094,7 @@ function buildIdlePromptWarning(command: string, reason: string | undefined): st
|
|
|
716
1094
|
if (rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest)) {
|
|
717
1095
|
const examplePrompt = reason.replace(/\s+/g, " ").trim();
|
|
718
1096
|
const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
|
|
719
|
-
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}
|
|
1097
|
+
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}"\`.`;
|
|
720
1098
|
}
|
|
721
1099
|
|
|
722
1100
|
return null;
|