pi-interactive-shell 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +101 -12
- package/SKILL.md +86 -17
- package/config.ts +10 -0
- package/headless-monitor.ts +171 -0
- package/index.ts +744 -366
- package/overlay-component.ts +134 -72
- package/package.json +2 -1
- package/pty-session.ts +36 -4
- package/reattach-overlay.ts +31 -4
- package/session-manager.ts +94 -45
- package/tool-schema.ts +61 -2
- package/types.ts +11 -3
package/session-manager.ts
CHANGED
|
@@ -47,14 +47,14 @@ export interface ActiveSession {
|
|
|
47
47
|
reason?: string;
|
|
48
48
|
write: (data: string) => void;
|
|
49
49
|
kill: () => void;
|
|
50
|
-
|
|
50
|
+
background: () => void;
|
|
51
|
+
getOutput: (options?: OutputOptions | boolean) => OutputResult;
|
|
51
52
|
getStatus: () => ActiveSessionStatus;
|
|
52
53
|
getRuntime: () => number;
|
|
53
|
-
getResult: () => ActiveSessionResult | undefined;
|
|
54
|
+
getResult: () => ActiveSessionResult | undefined;
|
|
54
55
|
setUpdateInterval?: (intervalMs: number) => void;
|
|
55
56
|
setQuietThreshold?: (thresholdMs: number) => void;
|
|
56
|
-
onComplete: (callback: () => void) => void;
|
|
57
|
-
startedAt: Date;
|
|
57
|
+
onComplete: (callback: () => void) => void;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// Human-readable session slug generation
|
|
@@ -124,7 +124,7 @@ export function releaseSessionId(id: string): void {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// Derive a friendly display name from command (e.g., "pi Fix all bugs" -> "pi Fix all bugs")
|
|
127
|
-
|
|
127
|
+
function deriveSessionName(command: string): string {
|
|
128
128
|
const trimmed = command.trim();
|
|
129
129
|
if (trimmed.length <= 60) return trimmed;
|
|
130
130
|
|
|
@@ -137,26 +137,21 @@ export class ShellSessionManager {
|
|
|
137
137
|
private exitWatchers = new Map<string, NodeJS.Timeout>();
|
|
138
138
|
private cleanupTimers = new Map<string, NodeJS.Timeout>();
|
|
139
139
|
private activeSessions = new Map<string, ActiveSession>();
|
|
140
|
+
private changeListeners = new Set<() => void>();
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
onComplete: (callback: () => void) => void;
|
|
155
|
-
}): void {
|
|
156
|
-
this.activeSessions.set(session.id, {
|
|
157
|
-
...session,
|
|
158
|
-
startedAt: new Date(),
|
|
159
|
-
});
|
|
142
|
+
onChange(listener: () => void): () => void {
|
|
143
|
+
this.changeListeners.add(listener);
|
|
144
|
+
return () => { this.changeListeners.delete(listener); };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private notifyChange(): void {
|
|
148
|
+
for (const listener of this.changeListeners) {
|
|
149
|
+
try { listener(); } catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
registerActive(session: ActiveSession): void {
|
|
154
|
+
this.activeSessions.set(session.id, session);
|
|
160
155
|
}
|
|
161
156
|
|
|
162
157
|
unregisterActive(id: string, releaseId = false): void {
|
|
@@ -193,42 +188,67 @@ export class ShellSessionManager {
|
|
|
193
188
|
return true;
|
|
194
189
|
}
|
|
195
190
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// Background session management
|
|
201
|
-
add(command: string, session: PtyTerminalSession, name?: string, reason?: string): string {
|
|
202
|
-
const id = generateSessionId(name);
|
|
191
|
+
add(command: string, session: PtyTerminalSession, name?: string, reason?: string, options?: { id?: string; noAutoCleanup?: boolean; startedAt?: Date }): string {
|
|
192
|
+
const id = options?.id ?? generateSessionId(name);
|
|
193
|
+
if (options?.id) usedIds.add(id);
|
|
203
194
|
this.sessions.set(id, {
|
|
204
195
|
id,
|
|
205
196
|
name: name || deriveSessionName(command),
|
|
206
197
|
command,
|
|
207
198
|
reason,
|
|
208
199
|
session,
|
|
209
|
-
startedAt: new Date(),
|
|
200
|
+
startedAt: options?.startedAt ?? new Date(),
|
|
210
201
|
});
|
|
211
202
|
|
|
212
203
|
session.setEventHandlers({});
|
|
213
204
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
205
|
+
if (!options?.noAutoCleanup) {
|
|
206
|
+
const checkExit = setInterval(() => {
|
|
207
|
+
if (session.exited) {
|
|
208
|
+
clearInterval(checkExit);
|
|
209
|
+
this.exitWatchers.delete(id);
|
|
210
|
+
this.notifyChange();
|
|
211
|
+
const cleanupTimer = setTimeout(() => {
|
|
212
|
+
this.cleanupTimers.delete(id);
|
|
213
|
+
this.remove(id);
|
|
214
|
+
}, 30000);
|
|
215
|
+
this.cleanupTimers.set(id, cleanupTimer);
|
|
216
|
+
}
|
|
217
|
+
}, 1000);
|
|
218
|
+
this.exitWatchers.set(id, checkExit);
|
|
219
|
+
}
|
|
226
220
|
|
|
221
|
+
this.notifyChange();
|
|
227
222
|
return id;
|
|
228
223
|
}
|
|
229
224
|
|
|
225
|
+
take(id: string): BackgroundSession | undefined {
|
|
226
|
+
const watcher = this.exitWatchers.get(id);
|
|
227
|
+
if (watcher) {
|
|
228
|
+
clearInterval(watcher);
|
|
229
|
+
this.exitWatchers.delete(id);
|
|
230
|
+
}
|
|
231
|
+
const cleanupTimer = this.cleanupTimers.get(id);
|
|
232
|
+
if (cleanupTimer) {
|
|
233
|
+
clearTimeout(cleanupTimer);
|
|
234
|
+
this.cleanupTimers.delete(id);
|
|
235
|
+
}
|
|
236
|
+
const session = this.sessions.get(id);
|
|
237
|
+
if (session) {
|
|
238
|
+
this.sessions.delete(id);
|
|
239
|
+
this.notifyChange();
|
|
240
|
+
return session;
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
230
245
|
get(id: string): BackgroundSession | undefined {
|
|
231
|
-
//
|
|
246
|
+
// Suspend all auto-cleanup while session is being actively used
|
|
247
|
+
const watcher = this.exitWatchers.get(id);
|
|
248
|
+
if (watcher) {
|
|
249
|
+
clearInterval(watcher);
|
|
250
|
+
this.exitWatchers.delete(id);
|
|
251
|
+
}
|
|
232
252
|
const cleanupTimer = this.cleanupTimers.get(id);
|
|
233
253
|
if (cleanupTimer) {
|
|
234
254
|
clearTimeout(cleanupTimer);
|
|
@@ -237,6 +257,34 @@ export class ShellSessionManager {
|
|
|
237
257
|
return this.sessions.get(id);
|
|
238
258
|
}
|
|
239
259
|
|
|
260
|
+
restartAutoCleanup(id: string): void {
|
|
261
|
+
if (this.exitWatchers.has(id)) return;
|
|
262
|
+
const entry = this.sessions.get(id);
|
|
263
|
+
if (!entry) return;
|
|
264
|
+
if (entry.session.exited) {
|
|
265
|
+
this.scheduleCleanup(id);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const checkExit = setInterval(() => {
|
|
269
|
+
if (entry.session.exited) {
|
|
270
|
+
clearInterval(checkExit);
|
|
271
|
+
this.exitWatchers.delete(id);
|
|
272
|
+
this.notifyChange();
|
|
273
|
+
this.scheduleCleanup(id);
|
|
274
|
+
}
|
|
275
|
+
}, 1000);
|
|
276
|
+
this.exitWatchers.set(id, checkExit);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
scheduleCleanup(id: string, delayMs = 30000): void {
|
|
280
|
+
if (this.cleanupTimers.has(id)) return;
|
|
281
|
+
const timer = setTimeout(() => {
|
|
282
|
+
this.cleanupTimers.delete(id);
|
|
283
|
+
this.remove(id);
|
|
284
|
+
}, delayMs);
|
|
285
|
+
this.cleanupTimers.set(id, timer);
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
remove(id: string): void {
|
|
241
289
|
const watcher = this.exitWatchers.get(id);
|
|
242
290
|
if (watcher) {
|
|
@@ -255,6 +303,7 @@ export class ShellSessionManager {
|
|
|
255
303
|
session.session.dispose();
|
|
256
304
|
this.sessions.delete(id);
|
|
257
305
|
releaseSessionId(id);
|
|
306
|
+
this.notifyChange();
|
|
258
307
|
}
|
|
259
308
|
}
|
|
260
309
|
|
package/tool-schema.ts
CHANGED
|
@@ -12,11 +12,13 @@ DO NOT use this for regular bash commands - use the standard bash tool instead.
|
|
|
12
12
|
MODES:
|
|
13
13
|
- interactive (default): User supervises and controls the session
|
|
14
14
|
- hands-free: Agent monitors with periodic updates, user can take over anytime by typing
|
|
15
|
+
- dispatch: Agent is notified on completion via triggerTurn (no polling needed)
|
|
15
16
|
|
|
16
17
|
The user will see the process in an overlay. They can:
|
|
17
18
|
- Watch output in real-time
|
|
18
19
|
- Scroll through output (Shift+Up/Down)
|
|
19
20
|
- Transfer output to you (Ctrl+T) - closes overlay and sends output as your context
|
|
21
|
+
- Background (Ctrl+B) - dismiss overlay, keep process running
|
|
20
22
|
- Detach (Ctrl+Q) for menu: transfer/background/kill
|
|
21
23
|
- In hands-free mode: type anything to take over control
|
|
22
24
|
|
|
@@ -68,6 +70,39 @@ TIMEOUT (for TUI commands that don't exit cleanly):
|
|
|
68
70
|
Use timeout to auto-kill after N milliseconds. Useful for capturing output from commands like "pi --help":
|
|
69
71
|
- interactive_shell({ command: "pi --help", mode: "hands-free", timeout: 5000 })
|
|
70
72
|
|
|
73
|
+
DISPATCH MODE (NON-BLOCKING, NO POLLING):
|
|
74
|
+
When mode="dispatch", the tool returns IMMEDIATELY with a sessionId.
|
|
75
|
+
You do NOT need to poll. You'll be notified automatically when the session completes.
|
|
76
|
+
|
|
77
|
+
Workflow:
|
|
78
|
+
1. Start session: interactive_shell({ command: 'pi "Fix bugs"', mode: "dispatch" })
|
|
79
|
+
-> Returns immediately with sessionId
|
|
80
|
+
2. Do other work - no polling needed
|
|
81
|
+
3. When complete, you receive a notification with the session output
|
|
82
|
+
|
|
83
|
+
Dispatch defaults autoExitOnQuiet to true (opt-out with handsFree.autoExitOnQuiet: false).
|
|
84
|
+
You can still query with sessionId if needed, but it's not required.
|
|
85
|
+
|
|
86
|
+
BACKGROUND DISPATCH (HEADLESS):
|
|
87
|
+
Start a session without any overlay. Process runs headlessly, agent notified on completion:
|
|
88
|
+
- interactive_shell({ command: 'pi "fix bugs"', mode: "dispatch", background: true })
|
|
89
|
+
|
|
90
|
+
AGENT-INITIATED BACKGROUND:
|
|
91
|
+
Dismiss an existing overlay, keep the process running in background:
|
|
92
|
+
- interactive_shell({ sessionId: "calm-reef", background: true })
|
|
93
|
+
|
|
94
|
+
ATTACH (REATTACH TO BACKGROUND SESSION):
|
|
95
|
+
Open an overlay for a background session:
|
|
96
|
+
- interactive_shell({ attach: "calm-reef" }) - interactive (blocking)
|
|
97
|
+
- interactive_shell({ attach: "calm-reef", mode: "dispatch" }) - dispatch (non-blocking, notified)
|
|
98
|
+
|
|
99
|
+
LIST BACKGROUND SESSIONS:
|
|
100
|
+
- interactive_shell({ listBackground: true })
|
|
101
|
+
|
|
102
|
+
DISMISS BACKGROUND SESSIONS:
|
|
103
|
+
- interactive_shell({ dismissBackground: true }) - kill running, remove exited, clear all
|
|
104
|
+
- interactive_shell({ dismissBackground: "calm-reef" }) - dismiss specific session
|
|
105
|
+
|
|
71
106
|
Important: this tool does NOT inject prompts. If you want to start with a prompt,
|
|
72
107
|
include it in the command using the CLI's own prompt flags.
|
|
73
108
|
|
|
@@ -165,7 +200,27 @@ export const toolParameters = Type.Object({
|
|
|
165
200
|
),
|
|
166
201
|
mode: Type.Optional(
|
|
167
202
|
Type.String({
|
|
168
|
-
description: "Mode: 'interactive' (default, user controls)
|
|
203
|
+
description: "Mode: 'interactive' (default, user controls), 'hands-free' (agent monitors, user can take over), or 'dispatch' (agent notified on completion, no polling needed)",
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
206
|
+
background: Type.Optional(
|
|
207
|
+
Type.Boolean({
|
|
208
|
+
description: "Run without overlay (with mode='dispatch') or dismiss existing overlay (with sessionId). Process runs in background, user can /attach.",
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
attach: Type.Optional(
|
|
212
|
+
Type.String({
|
|
213
|
+
description: "Background session ID to reattach. Opens overlay with the specified mode.",
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
listBackground: Type.Optional(
|
|
217
|
+
Type.Boolean({
|
|
218
|
+
description: "List all background sessions.",
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
dismissBackground: Type.Optional(
|
|
222
|
+
Type.Union([Type.Boolean(), Type.String()], {
|
|
223
|
+
description: "Dismiss background sessions. true = all, string = specific session ID. Kills running sessions, removes exited ones.",
|
|
169
224
|
}),
|
|
170
225
|
),
|
|
171
226
|
handsFree: Type.Optional(
|
|
@@ -235,7 +290,11 @@ export interface ToolParams {
|
|
|
235
290
|
cwd?: string;
|
|
236
291
|
name?: string;
|
|
237
292
|
reason?: string;
|
|
238
|
-
mode?: "interactive" | "hands-free";
|
|
293
|
+
mode?: "interactive" | "hands-free" | "dispatch";
|
|
294
|
+
background?: boolean;
|
|
295
|
+
attach?: string;
|
|
296
|
+
listBackground?: boolean;
|
|
297
|
+
dismissBackground?: boolean | string;
|
|
239
298
|
handsFree?: {
|
|
240
299
|
updateMode?: "on-quiet" | "interval";
|
|
241
300
|
updateInterval?: number;
|
package/types.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface InteractiveShellResult {
|
|
|
17
17
|
totalLines: number;
|
|
18
18
|
truncated: boolean;
|
|
19
19
|
};
|
|
20
|
+
/** Captured before PTY disposal for dispatch mode completion notifications */
|
|
21
|
+
completionOutput?: {
|
|
22
|
+
lines: string[];
|
|
23
|
+
totalLines: number;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
};
|
|
20
26
|
handoffPreview?: {
|
|
21
27
|
type: "tail";
|
|
22
28
|
when: "exit" | "detach" | "kill" | "timeout" | "transfer";
|
|
@@ -53,9 +59,9 @@ export interface InteractiveShellOptions {
|
|
|
53
59
|
handoffSnapshotEnabled?: boolean;
|
|
54
60
|
handoffSnapshotLines?: number;
|
|
55
61
|
handoffSnapshotMaxChars?: number;
|
|
56
|
-
// Hands-free mode
|
|
57
|
-
mode?: "interactive" | "hands-free";
|
|
58
|
-
sessionId?: string; // Pre-generated sessionId for
|
|
62
|
+
// Hands-free / dispatch mode
|
|
63
|
+
mode?: "interactive" | "hands-free" | "dispatch";
|
|
64
|
+
sessionId?: string; // Pre-generated sessionId for non-blocking modes
|
|
59
65
|
handsFreeUpdateMode?: "on-quiet" | "interval";
|
|
60
66
|
handsFreeUpdateInterval?: number;
|
|
61
67
|
handsFreeQuietThreshold?: number;
|
|
@@ -66,6 +72,8 @@ export interface InteractiveShellOptions {
|
|
|
66
72
|
autoExitOnQuiet?: boolean;
|
|
67
73
|
// Auto-kill timeout
|
|
68
74
|
timeout?: number;
|
|
75
|
+
// Existing PTY session (for attach flow -- skip creating a new PTY)
|
|
76
|
+
existingSession?: import("./pty-session.js").PtyTerminalSession;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export type DialogChoice = "kill" | "background" | "transfer" | "cancel";
|