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/CHANGELOG.md +63 -0
- package/README.md +173 -0
- package/SKILL.md +368 -0
- package/config.ts +132 -0
- package/index.ts +795 -0
- package/overlay-component.ts +1211 -0
- package/package.json +56 -0
- package/pty-session.ts +561 -0
- package/scripts/fix-spawn-helper.cjs +37 -0
- package/scripts/install.js +95 -0
- package/session-manager.ts +226 -0
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
|
+
}
|