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.
@@ -47,14 +47,14 @@ export interface ActiveSession {
47
47
  reason?: string;
48
48
  write: (data: string) => void;
49
49
  kill: () => void;
50
- getOutput: (options?: OutputOptions | boolean) => OutputResult; // Get output since last check (truncated if large)
50
+ background: () => void;
51
+ getOutput: (options?: OutputOptions | boolean) => OutputResult;
51
52
  getStatus: () => ActiveSessionStatus;
52
53
  getRuntime: () => number;
53
- getResult: () => ActiveSessionResult | undefined; // Available when completed
54
+ getResult: () => ActiveSessionResult | undefined;
54
55
  setUpdateInterval?: (intervalMs: number) => void;
55
56
  setQuietThreshold?: (thresholdMs: number) => void;
56
- onComplete: (callback: () => void) => void; // Register callback for when session completes
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
- export function deriveSessionName(command: string): string {
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
- // Active hands-free session management
142
- registerActive(session: {
143
- id: string;
144
- command: string;
145
- reason?: string;
146
- write: (data: string) => void;
147
- kill: () => void;
148
- getOutput: (options?: OutputOptions | boolean) => OutputResult;
149
- getStatus: () => ActiveSessionStatus;
150
- getRuntime: () => number;
151
- getResult: () => ActiveSessionResult | undefined;
152
- setUpdateInterval?: (intervalMs: number) => void;
153
- setQuietThreshold?: (thresholdMs: number) => void;
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
- listActive(): ActiveSession[] {
197
- return Array.from(this.activeSessions.values());
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
- const checkExit = setInterval(() => {
215
- if (session.exited) {
216
- clearInterval(checkExit);
217
- this.exitWatchers.delete(id);
218
- const cleanupTimer = setTimeout(() => {
219
- this.cleanupTimers.delete(id);
220
- this.remove(id);
221
- }, 30000);
222
- this.cleanupTimers.set(id, cleanupTimer);
223
- }
224
- }, 1000);
225
- this.exitWatchers.set(id, checkExit);
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
- // Cancel auto-cleanup timer when session is being reattached
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) or 'hands-free' (agent monitors, user can take over)",
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 hands-free mode
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";