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/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
- // Mode 1: Interact with existing session (query status, send input, kill, or change settings)
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 session if requested
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
- type: "text",
81
- text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}${hasMoreNote}:\n${output}` : ""}`,
82
- },
83
- ],
84
- details: {
85
- sessionId,
86
- status: "killed",
87
- runtime,
88
- output,
89
- outputTruncated: truncated,
90
- outputTotalBytes: totalBytes,
91
- outputTotalLines: totalLines,
92
- hasMore,
93
- previousStatus: status,
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
- const changed = sessionManager.setActiveUpdateInterval(sessionId, settings.updateInterval);
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
- const changed = sessionManager.setActiveQuietThreshold(sessionId, settings.quietThreshold);
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
- const inputDesc =
128
- typeof effectiveInput === "string"
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, true);
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
- // Get fresh output after waiting
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, true);
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
- // Mode 2: Start new session (requires command)
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
- type: "text",
356
- text: "Either 'command' (to start a session) or 'sessionId' (to query/interact with existing session) is required",
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
- // Generate sessionId early so it's available immediately
386
- const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
674
+ const generatedSessionId = isNonBlocking ? generateSessionId(name) : undefined;
387
675
 
388
- // For hands-free mode: non-blocking - return immediately with sessionId
389
- // Agent can then query status/output via sessionId and kill when done
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
- tui,
399
- theme,
400
- {
401
- command,
402
- cwd: effectiveCwd,
403
- name,
404
- reason,
405
- mode,
406
- sessionId: generatedSessionId,
407
- handsFreeUpdateMode: handsFree?.updateMode,
408
- handsFreeUpdateInterval: handsFree?.updateInterval,
409
- handsFreeQuietThreshold: handsFree?.quietThreshold,
410
- handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
411
- handsFreeMaxTotalChars: handsFree?.maxTotalChars,
412
- // Default autoExitOnQuiet to false - agent must opt-in for fire-and-forget tasks
413
- autoExitOnQuiet: handsFree?.autoExitOnQuiet === true,
414
- // No onHandsFreeUpdate in non-blocking mode - agent queries directly
415
- handoffPreviewEnabled: handoffPreview?.enabled,
416
- handoffPreviewLines: handoffPreview?.lines,
417
- handoffPreviewMaxChars: handoffPreview?.maxChars,
418
- handoffSnapshotEnabled: handoffSnapshot?.enabled,
419
- handoffSnapshotLines: handoffSnapshot?.lines,
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
- // Handle overlay completion in background (cleanup when user closes)
438
- overlayPromise.then((result) => {
439
- overlayOpen = false;
440
-
441
- // Handle Ctrl+T transfer: send output back to main agent
442
- if (result.transferred) {
443
- const truncatedNote = result.transferred.truncated
444
- ? ` (truncated from ${result.transferred.totalLines} total lines)`
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
- // Return immediately - agent can query via sessionId
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
- // Interactive mode: blocking - wait for overlay to close
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
- (tui, theme, _kb, done) =>
509
- new InteractiveShellOverlay(
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: isHandsFree
763
+ onHandsFreeUpdate: mode === "hands-free"
526
764
  ? (update) => {
527
- let statusText: string;
528
- switch (update.status) {
529
- case "user-takeover":
530
- statusText = `User took over session ${update.sessionId}`;
531
- break;
532
- case "exited":
533
- statusText = `Session ${update.sessionId} exited`;
534
- break;
535
- default: {
536
- const budgetInfo = update.budgetExhausted
537
- ? " [budget exhausted]"
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
- // User triggered "Transfer" action - output is the primary content
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
- const tailHeader = `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n`;
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 reason = sanitizedReason ? ` ${sanitizedReason}` : "";
652
- return `${s.id} - ${sanitizedCommand}${reason} (${status}, ${duration})`;
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} \"${clipped}\"\`.`;
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;