pi-interactive-shell 0.3.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 ADDED
@@ -0,0 +1,795 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { InteractiveShellOverlay, ReattachOverlay, type InteractiveShellResult } from "./overlay-component.js";
4
+ import { sessionManager, generateSessionId } from "./session-manager.js";
5
+ import { loadConfig } from "./config.js";
6
+
7
+ // Terminal escape sequences for named keys
8
+ // Named key sequences (without modifiers)
9
+ const NAMED_KEYS: Record<string, string> = {
10
+ // Arrow keys
11
+ up: "\x1b[A",
12
+ down: "\x1b[B",
13
+ left: "\x1b[D",
14
+ right: "\x1b[C",
15
+
16
+ // Common keys
17
+ enter: "\r",
18
+ return: "\r",
19
+ escape: "\x1b",
20
+ esc: "\x1b",
21
+ tab: "\t",
22
+ space: " ",
23
+ backspace: "\x7f",
24
+ bspace: "\x7f", // tmux-style alias
25
+
26
+ // Editing keys
27
+ delete: "\x1b[3~",
28
+ del: "\x1b[3~",
29
+ dc: "\x1b[3~", // tmux-style alias
30
+ insert: "\x1b[2~",
31
+ ic: "\x1b[2~", // tmux-style alias
32
+
33
+ // Navigation
34
+ home: "\x1b[H",
35
+ end: "\x1b[F",
36
+ pageup: "\x1b[5~",
37
+ pgup: "\x1b[5~",
38
+ ppage: "\x1b[5~", // tmux-style alias
39
+ pagedown: "\x1b[6~",
40
+ pgdn: "\x1b[6~",
41
+ npage: "\x1b[6~", // tmux-style alias
42
+
43
+ // Shift+Tab (backtab)
44
+ btab: "\x1b[Z",
45
+
46
+ // Function keys
47
+ f1: "\x1bOP",
48
+ f2: "\x1bOQ",
49
+ f3: "\x1bOR",
50
+ f4: "\x1bOS",
51
+ f5: "\x1b[15~",
52
+ f6: "\x1b[17~",
53
+ f7: "\x1b[18~",
54
+ f8: "\x1b[19~",
55
+ f9: "\x1b[20~",
56
+ f10: "\x1b[21~",
57
+ f11: "\x1b[23~",
58
+ f12: "\x1b[24~",
59
+
60
+ // Keypad keys (application mode)
61
+ kp0: "\x1bOp",
62
+ kp1: "\x1bOq",
63
+ kp2: "\x1bOr",
64
+ kp3: "\x1bOs",
65
+ kp4: "\x1bOt",
66
+ kp5: "\x1bOu",
67
+ kp6: "\x1bOv",
68
+ kp7: "\x1bOw",
69
+ kp8: "\x1bOx",
70
+ kp9: "\x1bOy",
71
+ "kp/": "\x1bOo",
72
+ "kp*": "\x1bOj",
73
+ "kp-": "\x1bOm",
74
+ "kp+": "\x1bOk",
75
+ "kp.": "\x1bOn",
76
+ kpenter: "\x1bOM",
77
+ };
78
+
79
+ // Ctrl+key combinations (ctrl+a through ctrl+z, plus some special)
80
+ const CTRL_KEYS: Record<string, string> = {};
81
+ for (let i = 0; i < 26; i++) {
82
+ const char = String.fromCharCode(97 + i); // a-z
83
+ CTRL_KEYS[`ctrl+${char}`] = String.fromCharCode(i + 1);
84
+ }
85
+ // Special ctrl combinations
86
+ CTRL_KEYS["ctrl+["] = "\x1b"; // Same as Escape
87
+ CTRL_KEYS["ctrl+\\"] = "\x1c";
88
+ CTRL_KEYS["ctrl+]"] = "\x1d";
89
+ CTRL_KEYS["ctrl+^"] = "\x1e";
90
+ CTRL_KEYS["ctrl+_"] = "\x1f";
91
+ CTRL_KEYS["ctrl+?"] = "\x7f"; // Same as Backspace
92
+
93
+ // Alt+key sends ESC followed by the key
94
+ function altKey(char: string): string {
95
+ return `\x1b${char}`;
96
+ }
97
+
98
+ // Keys that support xterm modifier encoding (CSI sequences)
99
+ const MODIFIABLE_KEYS = new Set([
100
+ "up", "down", "left", "right", "home", "end",
101
+ "pageup", "pgup", "ppage", "pagedown", "pgdn", "npage",
102
+ "insert", "ic", "delete", "del", "dc",
103
+ ]);
104
+
105
+ // Calculate xterm modifier code: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0)
106
+ function xtermModifier(shift: boolean, alt: boolean, ctrl: boolean): number {
107
+ let mod = 1;
108
+ if (shift) mod += 1;
109
+ if (alt) mod += 2;
110
+ if (ctrl) mod += 4;
111
+ return mod;
112
+ }
113
+
114
+ // Apply xterm modifier to CSI sequence: ESC[A -> ESC[1;modA
115
+ function applyXtermModifier(sequence: string, modifier: number): string | null {
116
+ // Arrow keys: ESC[A -> ESC[1;modA
117
+ const arrowMatch = sequence.match(/^\x1b\[([A-D])$/);
118
+ if (arrowMatch) {
119
+ return `\x1b[1;${modifier}${arrowMatch[1]}`;
120
+ }
121
+ // Numbered sequences: ESC[5~ -> ESC[5;mod~
122
+ const numMatch = sequence.match(/^\x1b\[(\d+)~$/);
123
+ if (numMatch) {
124
+ return `\x1b[${numMatch[1]};${modifier}~`;
125
+ }
126
+ // Home/End: ESC[H -> ESC[1;modH, ESC[F -> ESC[1;modF
127
+ const hfMatch = sequence.match(/^\x1b\[([HF])$/);
128
+ if (hfMatch) {
129
+ return `\x1b[1;${modifier}${hfMatch[1]}`;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // Bracketed paste mode sequences
135
+ const BRACKETED_PASTE_START = "\x1b[200~";
136
+ const BRACKETED_PASTE_END = "\x1b[201~";
137
+
138
+ function encodePaste(text: string, bracketed = true): string {
139
+ if (!bracketed) return text;
140
+ return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
141
+ }
142
+
143
+ // Parse a key token and return the escape sequence
144
+ function encodeKeyToken(token: string): string {
145
+ const normalized = token.trim().toLowerCase();
146
+ if (!normalized) return "";
147
+
148
+ // Check for direct match in named keys
149
+ if (NAMED_KEYS[normalized]) {
150
+ return NAMED_KEYS[normalized];
151
+ }
152
+
153
+ // Check for ctrl+key
154
+ if (CTRL_KEYS[normalized]) {
155
+ return CTRL_KEYS[normalized];
156
+ }
157
+
158
+ // Parse modifier prefixes: ctrl+alt+shift+key, c-m-s-key, etc.
159
+ let rest = normalized;
160
+ let ctrl = false, alt = false, shift = false;
161
+
162
+ // Support both "ctrl+alt+x" and "c-m-x" syntax
163
+ while (rest.length > 2) {
164
+ if (rest.startsWith("ctrl+") || rest.startsWith("ctrl-")) {
165
+ ctrl = true;
166
+ rest = rest.slice(5);
167
+ } else if (rest.startsWith("alt+") || rest.startsWith("alt-")) {
168
+ alt = true;
169
+ rest = rest.slice(4);
170
+ } else if (rest.startsWith("shift+") || rest.startsWith("shift-")) {
171
+ shift = true;
172
+ rest = rest.slice(6);
173
+ } else if (rest.startsWith("c-")) {
174
+ ctrl = true;
175
+ rest = rest.slice(2);
176
+ } else if (rest.startsWith("m-")) {
177
+ alt = true;
178
+ rest = rest.slice(2);
179
+ } else if (rest.startsWith("s-")) {
180
+ shift = true;
181
+ rest = rest.slice(2);
182
+ } else {
183
+ break;
184
+ }
185
+ }
186
+
187
+ // Handle shift+tab specially
188
+ if (shift && rest === "tab") {
189
+ return "\x1b[Z";
190
+ }
191
+
192
+ // Check if base key is a named key that supports modifiers
193
+ const baseSeq = NAMED_KEYS[rest];
194
+ if (baseSeq && MODIFIABLE_KEYS.has(rest) && (ctrl || alt || shift)) {
195
+ const mod = xtermModifier(shift, alt, ctrl);
196
+ if (mod > 1) {
197
+ const modified = applyXtermModifier(baseSeq, mod);
198
+ if (modified) return modified;
199
+ }
200
+ }
201
+
202
+ // For single character with modifiers
203
+ if (rest.length === 1) {
204
+ let char = rest;
205
+ if (shift && /[a-z]/.test(char)) {
206
+ char = char.toUpperCase();
207
+ }
208
+ if (ctrl) {
209
+ const ctrlChar = CTRL_KEYS[`ctrl+${char.toLowerCase()}`];
210
+ if (ctrlChar) char = ctrlChar;
211
+ }
212
+ if (alt) {
213
+ return altKey(char);
214
+ }
215
+ return char;
216
+ }
217
+
218
+ // Named key with alt modifier
219
+ if (baseSeq && alt) {
220
+ return `\x1b${baseSeq}`;
221
+ }
222
+
223
+ // Return base sequence if found
224
+ if (baseSeq) {
225
+ return baseSeq;
226
+ }
227
+
228
+ // Unknown key, return as literal
229
+ return token;
230
+ }
231
+
232
+ function translateInput(input: string | { text?: string; keys?: string[]; paste?: string; hex?: string[] }): string {
233
+ if (typeof input === "string") {
234
+ return input;
235
+ }
236
+
237
+ let result = "";
238
+
239
+ // Hex bytes (raw escape sequences)
240
+ if (input.hex?.length) {
241
+ for (const raw of input.hex) {
242
+ const trimmed = raw.trim().toLowerCase();
243
+ const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
244
+ if (/^[0-9a-f]{1,2}$/.test(normalized)) {
245
+ const value = Number.parseInt(normalized, 16);
246
+ if (!Number.isNaN(value) && value >= 0 && value <= 0xff) {
247
+ result += String.fromCharCode(value);
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ // Literal text
254
+ if (input.text) {
255
+ result += input.text;
256
+ }
257
+
258
+ // Named keys with modifier support
259
+ if (input.keys) {
260
+ for (const key of input.keys) {
261
+ result += encodeKeyToken(key);
262
+ }
263
+ }
264
+
265
+ // Bracketed paste
266
+ if (input.paste) {
267
+ result += encodePaste(input.paste);
268
+ }
269
+
270
+ return result;
271
+ }
272
+
273
+ export default function interactiveShellExtension(pi: ExtensionAPI) {
274
+ pi.on("session_shutdown", () => {
275
+ sessionManager.killAll();
276
+ });
277
+
278
+ pi.registerTool({
279
+ name: "interactive_shell",
280
+ label: "Interactive Shell",
281
+ description: `Run an interactive CLI coding agent in an overlay.
282
+
283
+ Use this ONLY for delegating tasks to other AI coding agents (Claude Code, Gemini CLI, Codex, etc.) that have their own TUI and benefit from user interaction.
284
+
285
+ DO NOT use this for regular bash commands - use the standard bash tool instead.
286
+
287
+ MODES:
288
+ - interactive (default): User supervises and controls the session
289
+ - hands-free: Agent monitors with periodic updates, user can take over anytime by typing
290
+
291
+ The user will see the process in an overlay. They can:
292
+ - Watch output in real-time
293
+ - Scroll through output (Shift+Up/Down)
294
+ - Detach (double-Escape) to kill or run in background
295
+ - In hands-free mode: type anything to take over control
296
+
297
+ HANDS-FREE MODE:
298
+ When mode="hands-free", you get updates when output stops (default: 5s of quiet).
299
+ Updates only include NEW output since the last update, not the full tail.
300
+ The user sees the overlay but you control the session. If user types anything,
301
+ they automatically take over and you'll be notified.
302
+
303
+ UPDATE MODES:
304
+ - on-quiet (default): Emit update after 5s of no output. Perfect for agent-to-agent delegation.
305
+ - interval: Emit on fixed schedule (every 60s). Use when continuous output is expected.
306
+
307
+ Max interval (60s) acts as fallback in on-quiet mode for long continuous output.
308
+
309
+ CONTEXT BUDGET:
310
+ Updates have a total budget (default: 100KB). Once exhausted, updates still arrive
311
+ but without content. You can adjust via handsFree.maxTotalChars.
312
+
313
+ Use hands-free when you want to monitor a long-running agent without blocking.
314
+
315
+ SENDING INPUT AND CHANGING SETTINGS:
316
+ In hands-free mode, you receive a sessionId in updates. Use this to send input:
317
+ - interactive_shell({ sessionId: "calm-reef", input: "/model\\n" })
318
+ - interactive_shell({ sessionId: "calm-reef", input: { text: "sonnet", keys: ["down", "enter"] } })
319
+
320
+ Change update frequency dynamically:
321
+ - interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 60000 } })
322
+
323
+ Named keys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
324
+ Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
325
+ Hex bytes: input: { hex: ["0x1b", "0x5b", "0x41"] } for raw escape sequences
326
+ Bracketed paste: input: { paste: "multiline\\ntext" } prevents auto-execution
327
+
328
+ TIMEOUT (for TUI commands that don't exit cleanly):
329
+ Use timeout to auto-kill after N milliseconds. Useful for capturing output from commands like "pi --help":
330
+ - interactive_shell({ command: "pi --help", mode: "hands-free", timeout: 5000 })
331
+
332
+ Important: this tool does NOT inject prompts. If you want to start with a prompt,
333
+ include it in the command using the CLI's own prompt flags.
334
+
335
+ Examples:
336
+ - pi "Scan the current codebase"
337
+ - claude "Check the current directory and summarize"
338
+ - gemini (interactive, idle)
339
+ - aider --yes-always (hands-free, auto-approve)
340
+ - pi --help (with timeout: 5000 to capture help output)`,
341
+
342
+ parameters: Type.Object({
343
+ command: Type.Optional(
344
+ Type.String({
345
+ description: "The CLI agent command (e.g., 'pi \"Fix the bug\"'). Required to start a new session.",
346
+ }),
347
+ ),
348
+ sessionId: Type.Optional(
349
+ Type.String({
350
+ description: "Session ID to send input to an existing hands-free session",
351
+ }),
352
+ ),
353
+ settings: Type.Optional(
354
+ Type.Object({
355
+ updateInterval: Type.Optional(
356
+ Type.Number({ description: "Change max update interval for existing session (ms)" }),
357
+ ),
358
+ quietThreshold: Type.Optional(
359
+ Type.Number({ description: "Change quiet threshold for existing session (ms)" }),
360
+ ),
361
+ }),
362
+ ),
363
+ input: Type.Optional(
364
+ Type.Union(
365
+ [
366
+ Type.String({ description: "Raw text/keystrokes to send" }),
367
+ Type.Object({
368
+ text: Type.Optional(Type.String({ description: "Text to type" })),
369
+ keys: Type.Optional(
370
+ Type.Array(Type.String(), {
371
+ description:
372
+ "Named keys with modifier support: up, down, enter, ctrl+c, alt+x, shift+tab, ctrl+alt+delete, etc.",
373
+ }),
374
+ ),
375
+ hex: Type.Optional(
376
+ Type.Array(Type.String(), {
377
+ description: "Hex bytes to send (e.g., ['0x1b', '0x5b', '0x41'] for ESC[A)",
378
+ }),
379
+ ),
380
+ paste: Type.Optional(
381
+ Type.String({
382
+ description: "Text to paste with bracketed paste mode (prevents auto-execution)",
383
+ }),
384
+ ),
385
+ }),
386
+ ],
387
+ { description: "Input to send to an existing session (requires sessionId)" },
388
+ ),
389
+ ),
390
+ cwd: Type.Optional(
391
+ Type.String({
392
+ description: "Working directory for the command",
393
+ }),
394
+ ),
395
+ name: Type.Optional(
396
+ Type.String({
397
+ description: "Optional session name (used for session IDs)",
398
+ }),
399
+ ),
400
+ reason: Type.Optional(
401
+ Type.String({
402
+ description:
403
+ "Brief explanation shown in the overlay header only (not passed to the subprocess)",
404
+ }),
405
+ ),
406
+ mode: Type.Optional(
407
+ Type.Union([Type.Literal("interactive"), Type.Literal("hands-free")], {
408
+ description: "interactive (default): user controls. hands-free: agent monitors, user can take over",
409
+ }),
410
+ ),
411
+ handsFree: Type.Optional(
412
+ Type.Object({
413
+ updateMode: Type.Optional(
414
+ Type.Union([Type.Literal("on-quiet"), Type.Literal("interval")], {
415
+ description: "on-quiet (default): emit when output stops. interval: emit on fixed schedule.",
416
+ }),
417
+ ),
418
+ updateInterval: Type.Optional(
419
+ Type.Number({ description: "Max interval between updates in ms (default: 60000)" }),
420
+ ),
421
+ quietThreshold: Type.Optional(
422
+ Type.Number({ description: "Silence duration before emitting update in on-quiet mode (default: 5000ms)" }),
423
+ ),
424
+ updateMaxChars: Type.Optional(
425
+ Type.Number({ description: "Max chars per update (default: 1500)" }),
426
+ ),
427
+ maxTotalChars: Type.Optional(
428
+ Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
429
+ ),
430
+ }),
431
+ ),
432
+ handoffPreview: Type.Optional(
433
+ Type.Object({
434
+ enabled: Type.Optional(Type.Boolean({ description: "Include last N lines in tool result details" })),
435
+ lines: Type.Optional(Type.Number({ description: "Tail lines to include (default from config)" })),
436
+ maxChars: Type.Optional(
437
+ Type.Number({ description: "Max chars to include in tail preview (default from config)" }),
438
+ ),
439
+ }),
440
+ ),
441
+ handoffSnapshot: Type.Optional(
442
+ Type.Object({
443
+ enabled: Type.Optional(Type.Boolean({ description: "Write a transcript snapshot on detach/exit" })),
444
+ lines: Type.Optional(Type.Number({ description: "Tail lines to capture (default from config)" })),
445
+ maxChars: Type.Optional(Type.Number({ description: "Max chars to write (default from config)" })),
446
+ }),
447
+ ),
448
+ timeout: Type.Optional(
449
+ Type.Number({
450
+ description: "Auto-kill process after N milliseconds. Useful for TUI commands that don't exit cleanly (e.g., 'pi --help')",
451
+ }),
452
+ ),
453
+ }),
454
+
455
+ async execute(_toolCallId, params, onUpdate, ctx) {
456
+ const {
457
+ command,
458
+ sessionId,
459
+ settings,
460
+ input,
461
+ cwd,
462
+ name,
463
+ reason,
464
+ mode,
465
+ handsFree,
466
+ handoffPreview,
467
+ handoffSnapshot,
468
+ timeout,
469
+ } = params as {
470
+ command?: string;
471
+ sessionId?: string;
472
+ settings?: { updateInterval?: number; quietThreshold?: number };
473
+ input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
474
+ cwd?: string;
475
+ name?: string;
476
+ reason?: string;
477
+ mode?: "interactive" | "hands-free";
478
+ handsFree?: {
479
+ updateMode?: "on-quiet" | "interval";
480
+ updateInterval?: number;
481
+ quietThreshold?: number;
482
+ updateMaxChars?: number;
483
+ maxTotalChars?: number;
484
+ };
485
+ handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
486
+ handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
487
+ timeout?: number;
488
+ };
489
+
490
+ // Mode 1: Interact with existing session (send input and/or change settings)
491
+ if (sessionId && (input !== undefined || settings)) {
492
+ const session = sessionManager.getActive(sessionId);
493
+ if (!session) {
494
+ return {
495
+ content: [{ type: "text", text: `Session not found or no longer active: ${sessionId}` }],
496
+ isError: true,
497
+ details: { sessionId, error: "session_not_found" },
498
+ };
499
+ }
500
+
501
+ const actions: string[] = [];
502
+
503
+ // Apply settings changes
504
+ if (settings?.updateInterval !== undefined) {
505
+ const changed = sessionManager.setActiveUpdateInterval(sessionId, settings.updateInterval);
506
+ if (changed) {
507
+ actions.push(`update interval set to ${settings.updateInterval}ms`);
508
+ }
509
+ }
510
+ if (settings?.quietThreshold !== undefined) {
511
+ const changed = sessionManager.setActiveQuietThreshold(sessionId, settings.quietThreshold);
512
+ if (changed) {
513
+ actions.push(`quiet threshold set to ${settings.quietThreshold}ms`);
514
+ }
515
+ }
516
+
517
+ // Send input if provided
518
+ if (input !== undefined) {
519
+ const translatedInput = translateInput(input);
520
+ const success = sessionManager.writeToActive(sessionId, translatedInput);
521
+
522
+ if (!success) {
523
+ return {
524
+ content: [{ type: "text", text: `Failed to send input to session: ${sessionId}` }],
525
+ isError: true,
526
+ details: { sessionId, error: "write_failed" },
527
+ };
528
+ }
529
+
530
+ const inputDesc =
531
+ typeof input === "string"
532
+ ? input.length > 50
533
+ ? `${input.slice(0, 50)}...`
534
+ : input
535
+ : [
536
+ input.text ?? "",
537
+ input.keys ? `keys:[${input.keys.join(",")}]` : "",
538
+ input.hex ? `hex:[${input.hex.length} bytes]` : "",
539
+ input.paste ? `paste:[${input.paste.length} chars]` : "",
540
+ ]
541
+ .filter(Boolean)
542
+ .join(" + ") || "(empty)";
543
+
544
+ actions.push(`sent: ${inputDesc}`);
545
+ }
546
+
547
+ return {
548
+ content: [{ type: "text", text: `Session ${sessionId}: ${actions.join(", ")}` }],
549
+ details: { sessionId, actions },
550
+ };
551
+ }
552
+
553
+ // Mode 2: Start new session (requires command)
554
+ if (!command) {
555
+ return {
556
+ content: [
557
+ {
558
+ type: "text",
559
+ text: "Either 'command' (to start a session) or 'sessionId' + 'input' (to send input) is required",
560
+ },
561
+ ],
562
+ isError: true,
563
+ details: {},
564
+ };
565
+ }
566
+
567
+ if (!ctx.hasUI) {
568
+ return {
569
+ content: [{ type: "text", text: "Interactive shell requires interactive TUI mode" }],
570
+ isError: true,
571
+ details: {},
572
+ };
573
+ }
574
+
575
+ const effectiveCwd = cwd ?? ctx.cwd;
576
+ const config = loadConfig(effectiveCwd);
577
+ const isHandsFree = mode === "hands-free";
578
+
579
+ // Generate sessionId early so it's available in the first update
580
+ const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
581
+
582
+ onUpdate?.({
583
+ content: [{ type: "text", text: `Opening${isHandsFree ? " (hands-free)" : ""}: ${command}` }],
584
+ details: {
585
+ exitCode: null,
586
+ backgrounded: false,
587
+ cancelled: false,
588
+ sessionId: generatedSessionId,
589
+ },
590
+ });
591
+
592
+ const result = await ctx.ui.custom<InteractiveShellResult>(
593
+ (tui, theme, _kb, done) =>
594
+ new InteractiveShellOverlay(
595
+ tui,
596
+ theme,
597
+ {
598
+ command,
599
+ cwd: effectiveCwd,
600
+ name,
601
+ reason,
602
+ mode,
603
+ sessionId: generatedSessionId,
604
+ handsFreeUpdateMode: handsFree?.updateMode,
605
+ handsFreeUpdateInterval: handsFree?.updateInterval,
606
+ handsFreeQuietThreshold: handsFree?.quietThreshold,
607
+ handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
608
+ handsFreeMaxTotalChars: handsFree?.maxTotalChars,
609
+ onHandsFreeUpdate: isHandsFree
610
+ ? (update) => {
611
+ let statusText: string;
612
+ switch (update.status) {
613
+ case "user-takeover":
614
+ statusText = `User took over session ${update.sessionId}`;
615
+ break;
616
+ case "exited":
617
+ statusText = `Session ${update.sessionId} exited`;
618
+ break;
619
+ default: {
620
+ const budgetInfo = update.budgetExhausted
621
+ ? " [budget exhausted]"
622
+ : "";
623
+ statusText = `Session ${update.sessionId} running (${formatDurationMs(update.runtime)})${budgetInfo}`;
624
+ }
625
+ }
626
+ // Only include new output if there is any
627
+ const newOutput =
628
+ update.status === "running" && update.tail.length > 0
629
+ ? `\n\n${update.tail.join("\n")}`
630
+ : "";
631
+ onUpdate?.({
632
+ content: [{ type: "text", text: statusText + newOutput }],
633
+ details: {
634
+ status: update.status,
635
+ sessionId: update.sessionId,
636
+ runtime: update.runtime,
637
+ newChars: update.tail.join("\n").length,
638
+ totalCharsSent: update.totalCharsSent,
639
+ budgetExhausted: update.budgetExhausted,
640
+ userTookOver: update.userTookOver,
641
+ },
642
+ });
643
+ }
644
+ : undefined,
645
+ handoffPreviewEnabled: handoffPreview?.enabled,
646
+ handoffPreviewLines: handoffPreview?.lines,
647
+ handoffPreviewMaxChars: handoffPreview?.maxChars,
648
+ handoffSnapshotEnabled: handoffSnapshot?.enabled,
649
+ handoffSnapshotLines: handoffSnapshot?.lines,
650
+ handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
651
+ timeout,
652
+ },
653
+ config,
654
+ done,
655
+ ),
656
+ {
657
+ overlay: true,
658
+ overlayOptions: {
659
+ width: `${config.overlayWidthPercent}%`,
660
+ maxHeight: `${config.overlayHeightPercent}%`,
661
+ anchor: "center",
662
+ margin: 1,
663
+ },
664
+ },
665
+ );
666
+
667
+ let summary: string;
668
+ if (result.backgrounded) {
669
+ summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
670
+ } else if (result.cancelled) {
671
+ summary = "User killed the interactive session";
672
+ } else if (result.timedOut) {
673
+ summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
674
+ } else {
675
+ const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
676
+ summary = `Session ended ${status}`;
677
+ }
678
+
679
+ if (result.userTookOver) {
680
+ summary += "\n\nNote: User took over control during hands-free mode.";
681
+ }
682
+
683
+ const warning = buildIdlePromptWarning(command, reason);
684
+ if (warning) {
685
+ summary += `\n\n${warning}`;
686
+ }
687
+
688
+ if (result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
689
+ const tailHeader = `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n`;
690
+ summary += tailHeader + result.handoffPreview.lines.join("\n");
691
+ }
692
+
693
+ return {
694
+ content: [{ type: "text", text: summary }],
695
+ details: result,
696
+ };
697
+ },
698
+ });
699
+
700
+ pi.registerCommand("attach", {
701
+ description: "Reattach to a background shell session",
702
+ handler: async (args, ctx) => {
703
+ const sessions = sessionManager.list();
704
+
705
+ if (sessions.length === 0) {
706
+ ctx.ui.notify("No background sessions", "info");
707
+ return;
708
+ }
709
+
710
+ let targetId = args.trim();
711
+
712
+ if (!targetId) {
713
+ const options = sessions.map((s) => {
714
+ const status = s.session.exited ? "exited" : "running";
715
+ const duration = formatDuration(Date.now() - s.startedAt.getTime());
716
+ const reason = s.reason ? ` • ${s.reason}` : "";
717
+ return `${s.id} - ${s.command}${reason} (${status}, ${duration})`;
718
+ });
719
+
720
+ const choice = await ctx.ui.select("Background Sessions", options);
721
+ if (!choice) return;
722
+ targetId = choice.split(" - ")[0]!;
723
+ }
724
+
725
+ const session = sessionManager.get(targetId);
726
+ if (!session) {
727
+ ctx.ui.notify(`Session not found: ${targetId}`, "error");
728
+ return;
729
+ }
730
+
731
+ const config = loadConfig(ctx.cwd);
732
+ await ctx.ui.custom<InteractiveShellResult>(
733
+ (tui, theme, _kb, done) =>
734
+ new ReattachOverlay(
735
+ tui,
736
+ theme,
737
+ { id: session.id, command: session.command, reason: session.reason, session: session.session },
738
+ config,
739
+ done,
740
+ ),
741
+ {
742
+ overlay: true,
743
+ overlayOptions: {
744
+ width: `${config.overlayWidthPercent}%`,
745
+ maxHeight: `${config.overlayHeightPercent}%`,
746
+ anchor: "center",
747
+ margin: 1,
748
+ },
749
+ },
750
+ );
751
+ },
752
+ });
753
+ }
754
+
755
+ function formatDuration(ms: number): string {
756
+ const seconds = Math.floor(ms / 1000);
757
+ if (seconds < 60) return `${seconds}s`;
758
+ const minutes = Math.floor(seconds / 60);
759
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
760
+ const hours = Math.floor(minutes / 60);
761
+ return `${hours}h ${minutes % 60}m`;
762
+ }
763
+
764
+ // Alias for clarity in hands-free update context
765
+ const formatDurationMs = formatDuration;
766
+
767
+ function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
768
+ if (!reason) return null;
769
+
770
+ const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
771
+ if (!tasky.test(reason)) return null;
772
+
773
+ const trimmed = command.trim();
774
+ const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
775
+ const bin = binaries.find((b) => trimmed === b || trimmed.startsWith(`${b} `));
776
+ if (!bin) return null;
777
+
778
+ // Consider "idle" when the command has no obvious positional prompt and only contains flags.
779
+ // This is intentionally conservative to avoid false positives.
780
+ const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
781
+ const hasQuotedPrompt = /["']/.test(rest);
782
+ const hasKnownPromptFlag =
783
+ /\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
784
+ (bin === "pi" && /\b-p\b/.test(rest)) ||
785
+ (bin === "codex" && /\bexec\b/.test(rest));
786
+
787
+ if (hasQuotedPrompt || hasKnownPromptFlag) return null;
788
+ if (rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest)) {
789
+ const examplePrompt = reason.replace(/\s+/g, " ").trim();
790
+ const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
791
+ 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}\"\`.`;
792
+ }
793
+
794
+ return null;
795
+ }