
Interactive Shell
- 65 installs
- 1.5k repo stars
- Updated August 5, 2026
- dicklesworthstone/pi_agent_rust
Launches and supervises interactive coding-agent CLIs (Claude, Gemini, Codex, Cursor, pi) via foreground, dispatch, or headless background sessions.
About
Provides a workflow and cheat sheet for delegating work to other coding-agent CLIs using the interactive_shell overlay or headless dispatch. A developer uses it to run long-running or fire-and-forget subagent sessions with status polling and input control.
- Foreground, dispatch, and background subagent modes with sessionId polling
- Named-key input, bracketed paste, and handoff via Ctrl+T transfer
Interactive Shell by the numbers
- 65 all-time installs (skills.sh)
- Ranked #6,085 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dicklesworthstone/pi_agent_rust --skill interactive-shellAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 65 |
|---|---|
| repo stars | ★ 1.5k |
| Last updated | August 5, 2026 |
| Repository | dicklesworthstone/pi_agent_rust ↗ |
What it does
Launches and supervises interactive coding-agent CLIs (Claude, Gemini, Codex, Cursor, pi) via foreground, dispatch, or headless background sessions.
Files
Interactive Shell (Skill)
Last verified: 2026-01-18
Foreground vs Background Subagents
Pi has two ways to delegate work to other AI coding agents:
| Foreground Subagents | Dispatch Subagents | Background Subagents | |
|---|---|---|---|
| Tool | interactive_shell | interactive_shell (dispatch) | subagent |
| Visibility | User sees overlay | User sees overlay (or headless) | Hidden from user |
| Agent model | Polls for status | Notified on completion | Full output captured |
| Default agent | pi (others if user requests) | pi (others if user requests) | Pi only |
| User control | Can take over anytime | Can take over anytime | No intervention |
| Best for | Long tasks needing supervision | Fire-and-forget delegations | Parallel tasks, structured delegation |
Foreground subagents run in an overlay where the user watches (and can intervene). Use interactive_shell with mode: "hands-free" to monitor while receiving periodic updates, or mode: "dispatch" to be notified on completion without polling.
Dispatch subagents also use interactive_shell but with mode: "dispatch". The agent fires the session and moves on. When the session completes, the agent is woken up via triggerTurn with the output in context. Add background: true for headless execution (no overlay).
Background subagents run invisibly via the subagent tool. Pi-only, but captures full output and supports parallel execution.
When to Use Foreground Subagents
Use interactive_shell (foreground) when:
- The task is long-running and the user should see progress
- The user might want to intervene or guide the agent
- You want hands-free monitoring with periodic status updates
- You need a different agent's capabilities (only if user specifies)
Use subagent (background) when:
- You need parallel execution of multiple tasks
- You want full output capture for processing
- The task is quick and deterministic
- User doesn't need to see the work happening
Default Agent Choice
Default to `pi` for foreground subagents unless the user explicitly requests a different agent:
| User says | Agent to use |
|---|---|
| "Run this in hands-free" | pi |
| "Delegate this task" | pi |
| "Use Claude to review this" | claude |
| "Have Gemini analyze this" | gemini |
| "Run aider to fix this" | aider |
Pi is the default because it's already available, has the same capabilities, and maintains consistency. Only use Claude, Gemini, Codex, or other agents when the user specifically asks for them.
Foreground Subagent Modes
Interactive (default)
User has full control, types directly into the agent.
interactive_shell({ command: 'pi' })Interactive with Initial Prompt
Agent starts working immediately, user supervises.
interactive_shell({ command: 'pi "Review this codebase for security issues"' })Dispatch (Fire-and-Forget) - NON-BLOCKING, NO POLLING
Agent fires a session and moves on. Notified automatically on completion via triggerTurn.
// Start session - returns immediately, no polling needed
interactive_shell({
command: 'pi "Fix all TypeScript errors in src/"',
mode: "dispatch",
reason: "Fixing TS errors"
})
// Returns: { sessionId: "calm-reef", mode: "dispatch" }
// → Do other work. When session completes, you receive notification with output.Dispatch defaults autoExitOnQuiet: true. The agent can still query the sessionId if needed, but doesn't have to.
Background Dispatch (Headless)
No overlay opens. Multiple headless dispatches can run concurrently:
interactive_shell({
command: 'pi "Fix lint errors"',
mode: "dispatch",
background: true
})
// → No overlay. User can /attach to watch. Agent notified on completion.Hands-Free (Foreground Subagent) - NON-BLOCKING
Agent works autonomously, returns immediately with sessionId. You query for status/output and kill when done.
// 1. Start session - returns immediately
interactive_shell({
command: 'pi "Fix all TypeScript errors in src/"',
mode: "hands-free",
reason: "Fixing TS errors"
})
// Returns: { sessionId: "calm-reef", status: "running" }
// 2. Check status and get new output
interactive_shell({ sessionId: "calm-reef" })
// Returns: { status: "running", output: "...", runtime: 30000 }
// 3. When you see task is complete, kill session
interactive_shell({ sessionId: "calm-reef", kill: true })
// Returns: { status: "killed", output: "final output..." }This is the primary pattern for foreground subagents - you delegate to pi (or another agent), query for progress, and decide when the task is done.
Hands-Free Workflow
Starting a Session
const result = interactive_shell({
command: 'codex "Review this codebase"',
mode: "hands-free"
})
// result.details.sessionId = "calm-reef"
// result.details.status = "running"The user sees the overlay immediately. You get control back to continue working.
Querying Status
interactive_shell({ sessionId: "calm-reef" })Returns:
status: "running" | "user-takeover" | "exited" | "killed" | "backgrounded"output: Last 20 lines of rendered terminal (clean, no TUI animation noise)runtime: Time elapsed in ms
Rate limited: Queries are limited to once every 60 seconds. If you query too soon, the tool will automatically wait until the limit expires before returning. The user is watching the overlay in real-time - you're just checking in periodically.
Ending a Session
interactive_shell({ sessionId: "calm-reef", kill: true })Kill when you see the task is complete in the output. Returns final status and output.
Fire-and-Forget Tasks
For single-task delegations where you don't need multi-turn interaction, enable auto-exit so the session kills itself when the agent goes quiet:
interactive_shell({
command: 'pi "Review this codebase for security issues. Save your findings to /tmp/security-review.md"',
mode: "hands-free",
reason: "Security review",
handsFree: { autoExitOnQuiet: true }
})
// Session auto-kills after ~5s of quiet
// Read results from file:
// read("/tmp/security-review.md")Instruct subagent to save results to a file since the session closes automatically.
Multi-Turn Sessions (default)
For back-and-forth interaction, leave auto-exit disabled (the default). Query status and kill manually when done:
interactive_shell({
command: 'cursor-agent -f',
mode: "hands-free",
reason: "Interactive refactoring"
})
// Send follow-up prompts
interactive_shell({ sessionId: "calm-reef", input: "Now fix the tests\n" })
// Kill when done
interactive_shell({ sessionId: "calm-reef", kill: true })Sending Input
interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] })
interactive_shell({ sessionId: "calm-reef", inputPaste: "multi\nline\ncode" })
interactive_shell({ sessionId: "calm-reef", input: "y", inputKeys: ["enter"] }) // combine text + keysQuery Output
Status queries return rendered terminal output (what's actually on screen), not raw stream:
- Default: 20 lines, 5KB max per query
- No TUI animation noise (spinners, progress bars, etc.)
- Configurable via
outputLines(max: 200) andoutputMaxChars(max: 50KB)
// Get more output when reviewing a session
interactive_shell({ sessionId: "calm-reef", outputLines: 50 })
// Get even more for detailed review
interactive_shell({ sessionId: "calm-reef", outputLines: 100, outputMaxChars: 30000 })Incremental Reading
Use incremental: true to paginate through output without re-reading:
// First call: get first 50 lines
interactive_shell({ sessionId: "calm-reef", outputLines: 50, incremental: true })
// → { output: "...", hasMore: true }
// Next call: get next 50 lines (server tracks position)
interactive_shell({ sessionId: "calm-reef", outputLines: 50, incremental: true })
// → { output: "...", hasMore: true }
// Keep calling until hasMore: false
interactive_shell({ sessionId: "calm-reef", outputLines: 50, incremental: true })
// → { output: "...", hasMore: false }The server tracks your read position - just keep calling with incremental: true to get the next chunk.
Reviewing Output
Query sessions to see progress. Increase limits when you need more context:
// Default: last 20 lines
interactive_shell({ sessionId: "calm-reef" })
// Get more lines when you need more context
interactive_shell({ sessionId: "calm-reef", outputLines: 50 })
// Get even more for detailed review
interactive_shell({ sessionId: "calm-reef", outputLines: 100, outputMaxChars: 30000 })Sending Input to Active Sessions
Use the sessionId from updates to send input to a running hands-free session:
Basic Input
// Send text
interactive_shell({ sessionId: "shell-1", input: "/help\n" })
// Send text with keys
interactive_shell({ sessionId: "shell-1", input: "/model", inputKeys: ["enter"] })
// Navigate menus
interactive_shell({ sessionId: "shell-1", inputKeys: ["down", "down", "enter"] })
// Interrupt
interactive_shell({ sessionId: "shell-1", inputKeys: ["ctrl+c"] })Named Keys
| Key | Description |
|---|---|
up, down, left, right | Arrow keys |
enter, return | Enter/Return |
escape, esc | Escape |
tab, shift+tab (or btab) | Tab / Back-tab |
backspace, bspace | Backspace |
delete, del, dc | Delete |
insert, ic | Insert |
home, end | Home/End |
pageup, pgup, ppage | Page Up |
pagedown, pgdn, npage | Page Down |
f1-f12 | Function keys |
kp0-kp9, kp/, kp*, kp-, kp+, kp., kpenter | Keypad keys |
ctrl+c, ctrl+d, ctrl+z | Control sequences |
ctrl+a through ctrl+z | All control keys |
Note: ic/dc, ppage/npage, bspace are tmux-style aliases for compatibility.
Modifier Combinations
Supports ctrl+, alt+, shift+ prefixes (or shorthand c-, m-, s-):
// Cancel
inputKeys: ["ctrl+c"]
// Alt+Tab
inputKeys: ["alt+tab"]
// Ctrl+Alt+Delete
inputKeys: ["ctrl+alt+delete"]
// Shorthand syntax
inputKeys: ["c-c", "m-x", "s-tab"]Hex Bytes (Advanced)
Send raw escape sequences:
inputHex: ["0x1b", "0x5b", "0x41"] // ESC[A (up arrow)Bracketed Paste
Paste multiline text without triggering autocompletion/execution:
inputPaste: "function foo() {\n return 42;\n}"Model Selection Example
// Step 1: Open model selector
interactive_shell({ sessionId: "shell-1", input: "/model", inputKeys: ["enter"] })
// Step 2: Filter and select (after ~500ms delay)
interactive_shell({ sessionId: "shell-1", input: "sonnet", inputKeys: ["enter"] })
// Or navigate with arrows:
interactive_shell({ sessionId: "shell-1", inputKeys: ["down", "down", "down", "enter"] })Context Compaction
interactive_shell({ sessionId: "shell-1", input: "/compact", inputKeys: ["enter"] })Changing Update Settings
Adjust timing during a session:
// Change max interval (fallback for on-quiet mode)
interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 120000 } })
// Change quiet threshold (how long to wait after output stops)
interactive_shell({ sessionId: "calm-reef", settings: { quietThreshold: 3000 } })
// Both at once
interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 30000, quietThreshold: 2000 } })CLI Quick Reference
| Agent | Interactive | With Prompt | Headless (bash) | Dispatch |
|---|---|---|---|---|
claude | claude | claude "prompt" | claude -p "prompt" | mode: "dispatch" |
gemini | gemini | gemini -i "prompt" | gemini "prompt" | mode: "dispatch" |
codex | codex | codex "prompt" | codex exec "prompt" | mode: "dispatch" |
agent | agent | agent "prompt" | agent -p "prompt" | mode: "dispatch" |
pi | pi | pi "prompt" | pi -p "prompt" | mode: "dispatch" |
Gemini model: gemini -m gemini-3-flash-preview -i "prompt"
Prompt Packaging Rules
The reason parameter is UI-only - it's shown in the overlay header but NOT passed to the subprocess.
To give the agent an initial prompt, embed it in the command:
// WRONG - agent starts idle, reason is just UI text
interactive_shell({ command: 'claude', reason: 'Review the codebase' })
// RIGHT - agent receives the prompt
interactive_shell({ command: 'claude "Review the codebase"', reason: 'Code review' })Handoff Options
Transfer (Ctrl+T) - Recommended
When the subagent finishes, the user presses Ctrl+T to transfer output directly to you:
[Subagent finishes work in overlay]
↓
[User presses Ctrl+T]
↓
[You receive: "Session output transferred (150 lines):
Completing skill integration...
Modified files:
- skills.ts
- agents/types/..."]This is the cleanest workflow - the subagent's response becomes your context automatically.
Configuration: transferLines (default: 200), transferMaxChars (default: 20KB)
Tail Preview (default)
Last 30 lines included in tool result. Good for seeing errors/final status.
Snapshot to File
Write full transcript to ~/.pi/agent/cache/interactive-shell/snapshot-*.log:
interactive_shell({
command: 'claude "Fix bugs"',
handoffSnapshot: { enabled: true, lines: 200 }
})Artifact Handoff (for complex tasks)
Instruct the delegated agent to write a handoff file:
Write your findings to .pi/delegation/claude-handoff.md including:
- What you did
- Files changed
- Any errors
- Next steps for the main agentSafe TUI Capture
Never run TUI agents via bash - they hang even with --help. Use interactive_shell with timeout instead:
interactive_shell({
command: "pi --help",
mode: "hands-free",
timeout: 5000 // Auto-kill after 5 seconds
})The process is killed after timeout and captured output is returned in the handoff preview. This is useful for:
- Getting CLI help from TUI applications
- Capturing output from commands that don't exit cleanly
- Any TUI command where you need quick output without user interaction
For pi CLI documentation, you can also read directly: /opt/homebrew/lib/node_modules/@mariozechner/pi-coding-agent/README.md
Background Session Management
// Background an active session (close overlay, keep running)
interactive_shell({ sessionId: "calm-reef", background: true })
// List all background sessions
interactive_shell({ listBackground: true })
// Reattach to a background session
interactive_shell({ attach: "calm-reef" }) // interactive (blocking)
interactive_shell({ attach: "calm-reef", mode: "hands-free" }) // hands-free (poll)
interactive_shell({ attach: "calm-reef", mode: "dispatch" }) // dispatch (notified)
// Dismiss background sessions (kill running, remove exited)
interactive_shell({ dismissBackground: true }) // all
interactive_shell({ dismissBackground: "calm-reef" }) // specificQuick Reference
Dispatch subagent (fire-and-forget, default to pi):
interactive_shell({
command: 'pi "Implement the feature described in SPEC.md"',
mode: "dispatch",
reason: "Implementing feature"
})
// Returns immediately. You'll be notified when done.Background dispatch (headless, no overlay):
interactive_shell({
command: 'pi "Fix lint errors"',
mode: "dispatch",
background: true,
reason: "Fixing lint"
})Start foreground subagent (hands-free, default to pi):
interactive_shell({
command: 'pi "Implement the feature described in SPEC.md"',
mode: "hands-free",
reason: "Implementing feature"
})
// Returns sessionId in updates, e.g., "shell-1"Send input to active session:
// Text with enter
interactive_shell({ sessionId: "calm-reef", input: "/compact\n" })
// Text + named keys
interactive_shell({ sessionId: "calm-reef", input: "/model", inputKeys: ["enter"] })
// Menu navigation
interactive_shell({ sessionId: "calm-reef", inputKeys: ["down", "down", "enter"] })Change update frequency:
interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 60000 } })Foreground subagent (user requested different agent):
interactive_shell({
command: 'claude "Review this code for security issues"',
mode: "hands-free",
reason: "Security review with Claude"
})Background subagent:
subagent({ agent: "scout", task: "Find all TODO comments" })Changelog
All notable changes to the pi-interactive-shell extension will be documented in this file.
[Unreleased]
[0.7.1] - 2026-02-03
Changed
- Added demo video and
pi.videofield to package.json for pi package browser.
[0.7.0] - 2026-02-03
Added
- Dispatch mode (
mode: "dispatch") - Fire-and-forget sessions where the agent is notified on completion viatriggerTurninstead of polling. DefaultsautoExitOnQuiet: true. - Background dispatch (
mode: "dispatch", background: true) - Headless sessions with no overlay. Multiple can run concurrently alongside an interactive overlay. - Agent-initiated background (
sessionId, background: true) - Dismiss an active overlay while keeping the process running. - Attach (
attach: "session-id") - Reattach to background sessions with any mode (interactive, hands-free, dispatch). - List background sessions (
listBackground: true) - Query all background sessions with status and duration. - Ctrl+B shortcut - Direct keyboard shortcut to background a session (dismiss overlay, keep process running) without navigating the Ctrl+Q menu.
- HeadlessDispatchMonitor - Lightweight monitor for background PTY sessions handling quiet timer, timeout, exit detection, and output capture.
- Completion output capture -
completionOutputcaptured before PTY disposal in allfinishWith*methods for dispatch notifications. completionNotifyLinesandcompletionNotifyMaxCharsconfig options for notification output size.- Dismiss background sessions -
/dismiss [id]user command anddismissBackgroundtool param to kill running / remove exited sessions without opening an overlay. - Background sessions widget - Persistent widget below the editor showing all background sessions with status indicators (
●running /○exited), session ID, command, reason, and live duration. Auto-appears/disappears. Responsive layout wraps to two lines on narrow terminals. - Additive listeners on PtyTerminalSession -
addDataListener()andaddExitListener()allow multiple subscribers alongside the primarysetEventHandlers(). Headless monitor and overlay coexist without conflicts.
Changed
sessionManager.add()now accepts optional{ id, noAutoCleanup }options for headless dispatch sessions.sessionManager.take()removes sessions from background registry without disposing PTY (for attach flow).ActiveSessioninterface now includesbackground()method.- Overlay
onExithandler broadened: non-blocking modes (dispatch and hands-free) auto-close immediately on exit instead of showing countdown. finishWithBackground()reuses sessionId as backgroundId for non-blocking modes.getOutputSinceLastCheck()returnscompletionOutputas fallback when session is finished./attachcommand coordinates with headless monitors via additive listeners (monitor stays active during overlay).- Headless dispatch completion notifications are compact: status line, duration, 5-line tail, and reattach instruction. Full output available via
details.completionOutputor by reattaching. - Completed headless sessions preserve their PTY for 5 minutes (
scheduleCleanup) instead of disposing immediately, allowing the agent to reattach and review full scrollback. - Notification tail strips trailing blank lines from terminal buffer before slicing.
Fixed
- Interval timer in
startHandsFreeUpdates()andsetUpdateInterval()no longer kills autoExitOnQuiet detection in dispatch mode (guarded on-quiet branch withonHandsFreeUpdatenull check). - Hands-free non-blocking polls returning empty output for completed sessions now return captured
completionOutput.
[0.6.4] - 2026-02-01
Fixed
- Adapt execute signature to pi v0.51.0: insert signal as 3rd parameter
[0.6.3] - 2026-01-30
Fixed
- Garbled output on Ctrl+T transfer - Transfer and handoff preview captured raw PTY output via
getRawStream(), which includes every intermediate frame of TUI spinners (e.g., Codex's "Working" spinner producedWorkingWorking•orking•rking•king•ing...). Switched bothcaptureTransferOutput()andmaybeBuildHandoffPreview()to usegetTailLines()which reads from the xterm terminal emulator buffer. The emulator correctly processes carriage returns and cursor movements, so only the final rendered state of each line is captured. Fixed in bothoverlay-component.tsandreattach-overlay.ts. - Removed dead code - Cleaned up unused private fields (
timedOut,lastDataTime) and unreachable method (getSessionId()) fromInteractiveShellOverlay.
[0.6.2] - 2026-01-28
Fixed
- Ctrl+T transfer now works in hands-free mode - When using Ctrl+T to transfer output in non-blocking hands-free mode, the captured output is now properly sent back to the main agent using
pi.sendMessage()withtriggerTurn: true. Previously, the transfer data was captured but never delivered to the agent because the tool had already returned. The fix uses the event bus pattern to wake the agent with the transferred content. - Race condition when Ctrl+T during polling - Added guard in
getOutputSinceLastCheck()to return empty output if the session is finished. This prevents errors when a query races with Ctrl+T transfer (PTY disposed before query completes).
Added
- New event: `interactive-shell:transfer` - Emitted via
pi.eventswhen Ctrl+T transfer occurs, allowing other extensions to hook into transfer events.
[0.6.1] - 2026-01-27
Added
- Banner image - Added fancy banner to README for consistent branding with other pi extensions
[0.6.0] - 2026-01-27
Added
- Transfer output to agent (Ctrl+T) - New action to capture subagent output and send it directly to the main agent. When a subagent finishes work, press Ctrl+T to close the overlay and transfer the output as primary content (not buried in details). The main agent immediately has the subagent's response in context.
- Transfer option in Ctrl+Q menu - "Transfer output to agent" is now the first option in the session menu, making it the default selection.
- Configurable transfer settings -
transferLines(default: 200, range: 10-1000) andtransferMaxChars(default: 20KB, range: 1KB-100KB) control how much output is captured.
Changed
- Ctrl+Q menu redesigned - Options are now: Transfer output → Run in background → Kill process → Cancel. Transfer is the default selection since it's the most common action when a subagent finishes.
- Footer hints updated - Now shows "Ctrl+T transfer • Ctrl+Q menu" for discoverability.
[0.5.3] - 2026-01-26
Changed
- Added
pi-packagekeyword for npm discoverability (pi v0.50.0 package system)
[0.5.2] - 2026-01-23
Fixed
- npx installation missing files - The install script had a hardcoded file list that was missing 4 critical files (
key-encoding.ts,types.ts,tool-schema.ts,reattach-overlay.ts). Now reads frompackage.json'sfilesarray as the single source of truth, ensuring all files are always copied. - Broken symlink handling - Fixed skill symlink creation failing when a broken symlink already existed at the target path.
existsSync()returnsfalsefor broken symlinks, causing the old code to skip removal. Now unconditionally attempts removal, correctly handling broken symlinks.
[0.5.1] - 2026-01-22
Fixed
- Prevent overlay stacking - Starting a new
interactive_shellsession or using/attachwhile an overlay is already open now returns an error instead of causing undefined behavior with stacked/stuck overlays.
[0.5.0] - 2026-01-22
Changed
- BREAKING: Split `input` into separate fields for Vertex AI compatibility - The
inputparameter which previously accepted either a string or an object withtext/keys/hex/pastefields has been split into separate parameters: input- Raw text/keystrokes (string only)inputKeys- Named keys array (e.g.,["ctrl+c", "enter"])inputHex- Hex bytes array for raw escape sequencesinputPaste- Text for bracketed paste mode
This change was required because Claude's Vertex AI API (google-antigravity provider) rejects anyOf JSON schemas with mixed primitive/object types.
Migration
// Before (0.4.x)
interactive_shell({ sessionId: "abc", input: { keys: ["ctrl+c"] } })
interactive_shell({ sessionId: "abc", input: { paste: "code" } })
// After (0.5.0)
interactive_shell({ sessionId: "abc", inputKeys: ["ctrl+c"] })
interactive_shell({ sessionId: "abc", inputPaste: "code" })
// Combining text with keys (still works)
interactive_shell({ sessionId: "abc", input: "y", inputKeys: ["enter"] })[0.4.9] - 2026-01-21
Fixed
- Multi-line command overflow in header - Commands containing newlines (e.g., long prompts passed via
-fflag) now properly collapse to a single line in the overlay header instead of overflowing and leaking behind the overlay. - Reason field overflow - The
reasonfield in the hint line is also sanitized to prevent newline overflow. - Session list overflow - The
/attachcommand's session list now sanitizes command and reason fields for proper display.
[0.4.8] - 2026-01-19
Changed
- node-pty ^1.1.0 - Updated minimum version to 1.1.0 which includes prebuilt binaries for macOS (arm64, x64) and Windows (x64, arm64). No more Xcode or Visual Studio required for installation on these platforms. Linux still requires build tools (
build-essential,python3).
[0.4.7] - 2026-01-18
Added
- Incremental mode - New
incremental: trueparameter for server-tracked pagination. Agent calls repeatedly and server tracks position automatically. ReturnshasMoreto indicate when more output is available. - hasMore in offset mode - Offset pagination now returns
hasMorefield so agents can know when they've finished reading all output.
Fixed
- Session ID leak on user takeover - In streaming mode, session ID was unregistered but never released when user took over. Now properly releases ID since agent was notified and won't query.
- Session ID leak in dispose() - When overlay was disposed without going through finishWith* methods (error cases), session ID was never released. Now releases ID in all cleanup paths.
Changed
- autoExitOnQuiet now defaults to false - Sessions stay alive for multi-turn interaction by default. Enable with
handsFree: { autoExitOnQuiet: true }for fire-and-forget single-task delegations. - Config documentation - Fixed incorrect config path in README. Config files are
~/.pi/agent/interactive-shell.json(global) and.pi/interactive-shell.json(project), not undersettings.json. Added full settings table with all options documented. - Detach key - Changed from double-Escape to Ctrl+Q for more reliable detection.
[0.4.6] - 2026-01-18
Added
- Offset/limit pagination - New
outputOffsetparameter for reading specific ranges of output: outputOffset: 0, outputLines: 50reads lines 0-49outputOffset: 50, outputLines: 50reads lines 50-99- Returns
totalLinesin response for pagination - Drain mode for incremental output - New
drain: trueparameter returns only NEW output since last query: - More token-efficient than re-reading the tail each time
- Ideal for repeated polling of long-running sessions
- Token Efficiency section in README - Documents advantages over tmux workflow:
- Incremental aggregation vs full capture-pane
- Tail by default (20 lines, not full history)
- ANSI stripping before sending to agent
- Drain mode for only-new-output
Changed
- getLogSlice() method in pty-session - New low-level method for offset/limit pagination through raw output buffer
[0.4.3] - 2026-01-18
Added
- Configurable output limits - New
outputLinesandoutputMaxCharsparameters when querying sessions: outputLines: Request more lines (default: 20, max: 200)outputMaxChars: Request more content (default: 5KB, max: 50KB)- Example:
interactive_shell({ sessionId: "calm-reef", outputLines: 50 }) - Escape hint feedback - After pressing first Escape, shows "Press Escape again to detach..." in footer for 300ms
Fixed
- Escape hint not showing - Fixed bug where
clearEscapeHint()was immediately resettingshowEscapeHintto false after setting it to true - Negative output limits - Added clamping to ensure
outputLinesandoutputMaxCharsare at least 1 - Reduced flickering during rapid output - Three improvements:
1. Scroll position calculated at render time via followBottom flag (not on each data event) 2. Debounced render requests (16ms) to batch rapid updates before drawing 3. Explicit scroll-to-bottom after resize to prevent flash to top during dimension changes
[0.4.2] - 2026-01-17
Added
- Query rate limiting - Queries are limited to once every 60 seconds by default. If you query too soon, the tool automatically waits until the limit expires before returning (blocking behavior). Configurable via
minQueryIntervalSecondsin settings (range: 5-300 seconds). Note: Rate limiting does not apply to completed sessions or kills - you can always query the final result immediately.
Changed
- autoExitOnQuiet now defaults to true - In hands-free mode, sessions auto-kill when output stops (~5s of quiet). Set
handsFree: { autoExitOnQuiet: false }to disable. - Smaller default overlay - Height reduced from 90% to 45%. Configurable via
overlayHeightPercentin settings (range: 20-90%).
Fixed
- Rate limit wait now interruptible - When waiting for rate limit, the wait is interrupted immediately if the session completes (user kills, process exits, etc.). Uses Promise.race with onComplete callback instead of blocking sleep.
- scrollbackLines NaN handling - Config now uses
clampIntlike other numeric fields, preventing NaN from breaking xterm scrollback. - autoExitOnQuiet status mismatch - Now sends "killed" status (not "exited") to match
finishWithKill()behavior. - hasNewOutput semantics - Renamed to
hasOutputsince we use tail-based output, not incremental tracking. - dispose() orphaned sessions - Now kills running processes before unregistering to prevent orphaned sessions.
- killAll() premature ID release - IDs now released via natural cleanup after process exit, not immediately after kill() call.
[0.4.1] - 2026-01-17
Changed
- Rendered output for queries - Status queries now return rendered terminal output (last 20 lines) instead of raw stream. This eliminates TUI animation noise (spinners, progress bars) and gives clean, readable content.
- Reduced output size - Max 20 lines and 5KB per query (down from 100 lines and 10KB). Queries are for checking in, not dumping full output.
Fixed
- TUI noise in query output - Raw stream captured all terminal animation (spinner text fragments like "Working", "orking", "rking"). Now uses xterm rendered buffer which shows clean final state.
[0.4.0] - 2026-01-17
Added
- Non-blocking hands-free mode - Major change:
mode: "hands-free"now returns immediately with a sessionId. The overlay opens for the user but the agent gets control back right away. Useinteractive_shell({ sessionId })to query status/output andinteractive_shell({ sessionId, kill: true })to end the session when done. - Session status queries - Query active session with just
sessionIdto get current status and any new output since last check. - Kill option -
interactive_shell({ sessionId, kill: true })to programmatically end a session. - autoExitOnQuiet option - Auto-kill session when output stops (after quietThreshold). Use
handsFree: { autoExitOnQuiet: true }for sessions that should end when the nested agent goes quiet. - Output truncation - Status queries now truncate output to 10KB (keeping the most recent content) to prevent overwhelming agent context. Truncation is indicated in the response.
Fixed
- Non-blocking mode session lifecycle - Sessions now stay registered after completion so agent can query final status. Previously, sessions were unregistered before agent could query completion result.
- User takeover in non-blocking mode - Agent can now see "user-takeover" status when querying. Previously, session was immediately unregistered when user took over.
- Type mismatch in registerActive - Fixed
getOutputreturn type to matchOutputResultinterface. - Agent output position after buffer trim - Fixed
agentOutputPositionbecoming stale when raw buffer is trimmed. When the 1MB buffer limit is exceeded and old content discarded, the agent query position is now clamped to prevent returning empty output or missing data. - killAll() map iteration - Fixed modifying maps during iteration in
killAll(). Now collects IDs/entries first to avoid unpredictable behavior when killing sessions triggers unregistration callbacks. - ActiveSessionResult type - Fixed type mismatch where
outputfield was required but never populated. Updated interface to match actual return type fromgetResult(). - Unbounded raw output growth - rawOutput buffer now capped at 1MB, trimming old content to prevent memory growth in long-running sessions
- Session ID reuse - IDs are only released when session fully terminates, preventing reuse while session still running after takeover
- DSR cursor responses - Fixed stale cursor position when DSR appears mid-chunk; now processes chunks in order, writing to xterm before responding
- Active sessions on shutdown - Hands-free sessions are now killed on
session_shutdown, preventing orphan processes - Quiet threshold timer - Changing threshold now restarts any active quiet timer with the new value
- Empty string input - Now shows "(empty)" instead of blank in success message
- Hands-free auto-close on exit - Overlay now closes immediately when process exits in hands-free mode, returning control to the agent instead of waiting for countdown
- Handoff preview now uses raw output stream instead of xterm buffer. TUI apps using alternate screen buffer (like Codex, Claude, etc.) would show misleading/stale content in the preview.
[0.3.0] - 2026-01-17
Added
- Hands-free mode (
mode: "hands-free") for agent-driven monitoring with periodic tail updates. - User can take over hands-free sessions by typing anything (except scroll keys).
- Configurable update settings for hands-free mode (defaults: on-quiet mode, 5s quiet threshold, 60s max interval, 1500 chars/update, 100KB total budget).
- Input injection: Send input to active hands-free sessions via
sessionId+inputparameters. - Named key support:
up,down,enter,escape,ctrl+c, etc. - "Foreground subagents" terminology to distinguish from background subagents (the
subagenttool). sessionIdnow available in the first update (before overlay opens) for immediate input injection.- Timeout: Auto-kill process after N milliseconds via
timeoutparameter. Useful for TUI commands that don't exit cleanly (e.g.,pi --help). - DSR handling: Automatically responds to cursor position queries (
ESC[6n/ESC[?6n) with actual xterm cursor position. Prevents TUI apps from hanging when querying cursor. - Enhanced key encoding: Full modifier support (
ctrl+alt+x,shift+tab,c-m-delete), hex bytes (hex: ["0x1b"]), bracketed paste mode (paste: "text"), and all F1-F12 keys. - Human-readable session IDs: Sessions now get memorable names like
calm-reef,swift-coveinstead ofshell-1,shell-2. - Process tree killing: Kill entire process tree on termination, preventing orphan child processes.
- Session name derivation: Better display names in
/attachlist showing command summary. - Write queue: Ordered writes to terminal emulator prevent race conditions.
- Raw output streaming:
getRawStream()method for incremental output reading withsinceLastoption. - Exit message in terminal: Process exit status appended to terminal buffer when process exits.
- EOL conversion: Added
convertEol: trueto xterm for consistent line ending handling. - Incremental updates: Hands-free updates now send only NEW output since last update, not full tail. Dramatically reduces context bloat.
- Activity-driven updates (on-quiet mode): Default behavior now waits for 5s of output silence before emitting update. Perfect for agent-to-agent delegation where you want complete "thoughts" not fragments.
- Update modes:
handsFree.updateModecan be"on-quiet"(default) or"interval". On-quiet emits when output stops; interval emits on fixed schedule. - Context budget: Total character budget (default: 100KB, configurable via
handsFree.maxTotalChars). Updates stop including content when exhausted. - Dynamic settings: Change update interval and quiet threshold mid-session via
settings: { updateInterval, quietThreshold }. - Keypad keys: Added
kp0-kp9,kp/,kp*,kp-,kp+,kp.,kpenterfor numpad input. - tmux-style key aliases: Added
ppage/npage(PageUp/PageDown),ic/dc(Insert/Delete),bspace(Backspace) for compatibility.
Changed
- ANSI stripping now uses Node.js built-in
stripVTControlCharactersfor cleaner, more robust output processing.
Fixed
- Double unregistration in hands-free session cleanup (now idempotent via
sessionUnregisteredflag). - Potential double
done()call when timeout fires and process exits simultaneously (addedfinishedguard). - ReattachOverlay: untracked setTimeout for initial countdown could fire after dispose (now tracked).
- Input type annotation missing
hexandpastefields. - Background session auto-cleanup could dispose session while user is viewing it via
/attach(now cancels timer on reattach). - On-quiet mode now flushes pending output before sending "exited" or "user-takeover" notifications (prevents data loss).
- Interval mode now also flushes pending output on user takeover (was missing the
|| updateMode === "interval"check). - Timeout in hands-free mode now flushes pending output and sends "exited" notification before returning.
- Exit handler now waits for writeQueue to drain, ensuring exit message is in rawOutput before notification is sent.
Removed
handsFree.updateLinesoption (was defined but unused after switch to incremental char-based updates).
[0.2.0] - 2026-01-17
Added
- Interactive shell overlay tool
interactive_shellfor supervising interactive CLI agent sessions. - Detach dialog (double
Esc) with kill/background/cancel. - Background session reattach command:
/attach. - Scroll support:
Shift+Up/Shift+Down. - Tail handoff preview included in tool result (bounded).
- Optional snapshot-to-file transcript handoff (disabled by default).
Fixed
- Prevented TUI width crashes by avoiding unbounded terminal escape rendering.
- Reduced flicker by sanitizing/redrawing in a controlled overlay viewport.
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export interface InteractiveShellConfig {
exitAutoCloseDelay: number;
overlayWidthPercent: number;
overlayHeightPercent: number;
scrollbackLines: number;
ansiReemit: boolean;
handoffPreviewEnabled: boolean;
handoffPreviewLines: number;
handoffPreviewMaxChars: number;
handoffSnapshotEnabled: boolean;
handoffSnapshotLines: number;
handoffSnapshotMaxChars: number;
// Transfer output settings (Ctrl+T)
transferLines: number;
transferMaxChars: number;
// Dispatch completion notification output
completionNotifyLines: number;
completionNotifyMaxChars: number;
// Hands-free mode defaults
handsFreeUpdateMode: "on-quiet" | "interval";
handsFreeUpdateInterval: number;
handsFreeQuietThreshold: number;
handsFreeUpdateMaxChars: number;
handsFreeMaxTotalChars: number;
// Query rate limiting
minQueryIntervalSeconds: number;
}
const DEFAULT_CONFIG: InteractiveShellConfig = {
exitAutoCloseDelay: 10,
overlayWidthPercent: 95,
overlayHeightPercent: 45,
scrollbackLines: 5000,
ansiReemit: true,
handoffPreviewEnabled: true,
handoffPreviewLines: 30,
handoffPreviewMaxChars: 2000,
handoffSnapshotEnabled: false,
handoffSnapshotLines: 200,
handoffSnapshotMaxChars: 12000,
// Transfer output settings (Ctrl+T) - generous defaults for full context transfer
transferLines: 200,
transferMaxChars: 20000,
// Dispatch completion notification output (between handoff preview and transfer)
completionNotifyLines: 50,
completionNotifyMaxChars: 5000,
// Hands-free mode defaults
handsFreeUpdateMode: "on-quiet" as const,
handsFreeUpdateInterval: 60000,
handsFreeQuietThreshold: 5000,
handsFreeUpdateMaxChars: 1500,
handsFreeMaxTotalChars: 100000,
// Query rate limiting (default 60 seconds between queries)
minQueryIntervalSeconds: 60,
};
export function loadConfig(cwd: string): InteractiveShellConfig {
const projectPath = join(cwd, ".pi", "interactive-shell.json");
const globalPath = join(homedir(), ".pi", "agent", "interactive-shell.json");
let globalConfig: Partial<InteractiveShellConfig> = {};
let projectConfig: Partial<InteractiveShellConfig> = {};
if (existsSync(globalPath)) {
try {
globalConfig = JSON.parse(readFileSync(globalPath, "utf-8"));
} catch (error) {
console.error(`Warning: Could not parse ${globalPath}: ${String(error)}`);
}
}
if (existsSync(projectPath)) {
try {
projectConfig = JSON.parse(readFileSync(projectPath, "utf-8"));
} catch (error) {
console.error(`Warning: Could not parse ${projectPath}: ${String(error)}`);
}
}
const merged = { ...DEFAULT_CONFIG, ...globalConfig, ...projectConfig };
return {
...merged,
exitAutoCloseDelay: clampInt(merged.exitAutoCloseDelay, DEFAULT_CONFIG.exitAutoCloseDelay, 0, 60),
overlayWidthPercent: clampPercent(merged.overlayWidthPercent, DEFAULT_CONFIG.overlayWidthPercent),
// Height: 20-90% range (default 45%)
overlayHeightPercent: clampInt(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent, 20, 90),
scrollbackLines: clampInt(merged.scrollbackLines, DEFAULT_CONFIG.scrollbackLines, 200, 50000),
ansiReemit: merged.ansiReemit !== false,
handoffPreviewEnabled: merged.handoffPreviewEnabled !== false,
handoffPreviewLines: clampInt(merged.handoffPreviewLines, DEFAULT_CONFIG.handoffPreviewLines, 0, 500),
handoffPreviewMaxChars: clampInt(
merged.handoffPreviewMaxChars,
DEFAULT_CONFIG.handoffPreviewMaxChars,
0,
50000,
),
handoffSnapshotEnabled: merged.handoffSnapshotEnabled === true,
handoffSnapshotLines: clampInt(merged.handoffSnapshotLines, DEFAULT_CONFIG.handoffSnapshotLines, 0, 5000),
handoffSnapshotMaxChars: clampInt(
merged.handoffSnapshotMaxChars,
DEFAULT_CONFIG.handoffSnapshotMaxChars,
0,
200000,
),
// Transfer output settings (Ctrl+T)
transferLines: clampInt(merged.transferLines, DEFAULT_CONFIG.transferLines, 10, 1000),
transferMaxChars: clampInt(merged.transferMaxChars, DEFAULT_CONFIG.transferMaxChars, 1000, 100000),
// Dispatch completion notification output
completionNotifyLines: clampInt(merged.completionNotifyLines, DEFAULT_CONFIG.completionNotifyLines, 10, 500),
completionNotifyMaxChars: clampInt(merged.completionNotifyMaxChars, DEFAULT_CONFIG.completionNotifyMaxChars, 1000, 50000),
// Hands-free mode
handsFreeUpdateMode: merged.handsFreeUpdateMode === "interval" ? "interval" : "on-quiet",
handsFreeUpdateInterval: clampInt(
merged.handsFreeUpdateInterval,
DEFAULT_CONFIG.handsFreeUpdateInterval,
5000,
300000,
),
handsFreeQuietThreshold: clampInt(
merged.handsFreeQuietThreshold,
DEFAULT_CONFIG.handsFreeQuietThreshold,
1000,
30000,
),
handsFreeUpdateMaxChars: clampInt(
merged.handsFreeUpdateMaxChars,
DEFAULT_CONFIG.handsFreeUpdateMaxChars,
500,
50000,
),
handsFreeMaxTotalChars: clampInt(
merged.handsFreeMaxTotalChars,
DEFAULT_CONFIG.handsFreeMaxTotalChars,
10000,
1000000,
),
// Query rate limiting (min 5 seconds, max 300 seconds)
minQueryIntervalSeconds: clampInt(
merged.minQueryIntervalSeconds,
DEFAULT_CONFIG.minQueryIntervalSeconds,
5,
300,
),
};
}
function clampPercent(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
return Math.min(100, Math.max(10, value));
}
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
const rounded = Math.trunc(value);
return Math.min(max, Math.max(min, rounded));
}
import type { PtyTerminalSession } from "./pty-session.js";
import type { InteractiveShellConfig } from "./config.js";
export interface HeadlessMonitorOptions {
autoExitOnQuiet: boolean;
quietThreshold: number;
timeout?: number;
}
export interface HeadlessCompletionInfo {
exitCode: number | null;
signal?: number;
timedOut?: boolean;
cancelled?: boolean;
completionOutput?: {
lines: string[];
totalLines: number;
truncated: boolean;
};
}
export class HeadlessDispatchMonitor {
readonly startTime = Date.now();
private _disposed = false;
private quietTimer: ReturnType<typeof setTimeout> | null = null;
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
private result: HeadlessCompletionInfo | undefined;
private completeCallbacks: Array<() => void> = [];
private unsubData: (() => void) | null = null;
private unsubExit: (() => void) | null = null;
get disposed(): boolean { return this._disposed; }
constructor(
private session: PtyTerminalSession,
private config: InteractiveShellConfig,
private options: HeadlessMonitorOptions,
private onComplete: (info: HeadlessCompletionInfo) => void,
) {
this.subscribe();
if (options.timeout && options.timeout > 0) {
this.timeoutTimer = setTimeout(() => {
this.handleCompletion(null, undefined, true);
}, options.timeout);
}
if (session.exited) {
queueMicrotask(() => {
if (!this._disposed) {
this.handleCompletion(session.exitCode, session.signal);
}
});
}
}
private subscribe(): void {
this.unsubscribe();
this.unsubData = this.session.addDataListener(() => {
if (this.options.autoExitOnQuiet) {
this.resetQuietTimer();
}
});
this.unsubExit = this.session.addExitListener((exitCode, signal) => {
if (!this._disposed) {
this.handleCompletion(exitCode, signal);
}
});
}
private unsubscribe(): void {
this.unsubData?.();
this.unsubData = null;
this.unsubExit?.();
this.unsubExit = null;
}
private resetQuietTimer(): void {
this.stopQuietTimer();
this.quietTimer = setTimeout(() => {
this.quietTimer = null;
if (!this._disposed && this.options.autoExitOnQuiet) {
this.session.kill();
this.handleCompletion(null, undefined, false, true);
}
}, this.options.quietThreshold);
}
private stopQuietTimer(): void {
if (this.quietTimer) {
clearTimeout(this.quietTimer);
this.quietTimer = null;
}
}
private captureOutput(): HeadlessCompletionInfo["completionOutput"] {
try {
const result = this.session.getTailLines({
lines: this.config.completionNotifyLines,
ansi: false,
maxChars: this.config.completionNotifyMaxChars,
});
return {
lines: result.lines,
totalLines: result.totalLinesInBuffer,
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
};
} catch {
return { lines: [], totalLines: 0, truncated: false };
}
}
private handleCompletion(exitCode: number | null, signal?: number, timedOut?: boolean, cancelled?: boolean): void {
if (this._disposed) return;
this._disposed = true;
this.stopQuietTimer();
if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; }
this.unsubscribe();
if (timedOut) {
this.session.kill();
}
const completionOutput = this.captureOutput();
const info: HeadlessCompletionInfo = { exitCode, signal, timedOut, cancelled, completionOutput };
this.result = info;
this.triggerCompleteCallbacks();
this.onComplete(info);
}
handleExternalCompletion(exitCode: number | null, signal?: number, completionOutput?: HeadlessCompletionInfo["completionOutput"]): void {
if (this._disposed) return;
this._disposed = true;
this.stopQuietTimer();
if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; }
this.unsubscribe();
const output = completionOutput ?? this.captureOutput();
const info: HeadlessCompletionInfo = { exitCode, signal, completionOutput: output };
this.result = info;
this.triggerCompleteCallbacks();
this.onComplete(info);
}
getResult(): HeadlessCompletionInfo | undefined {
return this.result;
}
registerCompleteCallback(callback: () => void): void {
if (this.result) {
callback();
return;
}
this.completeCallbacks.push(callback);
}
private triggerCompleteCallbacks(): void {
for (const cb of this.completeCallbacks) {
try { cb(); } catch { /* ignore */ }
}
this.completeCallbacks = [];
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stopQuietTimer();
if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; }
this.unsubscribe();
}
}
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { InteractiveShellOverlay } from "./overlay-component.js";
import { ReattachOverlay } from "./reattach-overlay.js";
import { PtyTerminalSession } from "./pty-session.js";
import type { InteractiveShellResult } from "./types.js";
import { sessionManager, generateSessionId, releaseSessionId } from "./session-manager.js";
import type { OutputOptions, OutputResult } from "./session-manager.js";
import { loadConfig } from "./config.js";
import type { InteractiveShellConfig } from "./config.js";
import { translateInput } from "./key-encoding.js";
import { TOOL_NAME, TOOL_LABEL, TOOL_DESCRIPTION, toolParameters, type ToolParams } from "./tool-schema.js";
import { formatDuration, formatDurationMs } from "./types.js";
import { HeadlessDispatchMonitor } from "./headless-monitor.js";
import type { HeadlessCompletionInfo } from "./headless-monitor.js";
let overlayOpen = false;
let agentHandledCompletion = false;
const headlessMonitors = new Map<string, HeadlessDispatchMonitor>();
function getHeadlessOutput(session: PtyTerminalSession, opts?: OutputOptions | boolean): OutputResult {
const options = typeof opts === "boolean" ? {} : (opts ?? {});
const lines = options.lines ?? 20;
const maxChars = options.maxChars ?? 5 * 1024;
try {
const result = session.getTailLines({ lines, ansi: false, maxChars });
const output = result.lines.join("\n");
return {
output,
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
totalBytes: output.length,
totalLines: result.totalLinesInBuffer,
};
} catch {
return { output: "", truncated: false, totalBytes: 0 };
}
}
const BRIEF_TAIL_LINES = 5;
function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
const parts: string[] = [];
if (info.timedOut) {
parts.push(`Session ${sessionId} timed out (${duration}).`);
} else if (info.cancelled) {
parts.push(`Session ${sessionId} completed (${duration}).`);
} else if (info.exitCode === 0) {
parts.push(`Session ${sessionId} completed successfully (${duration}).`);
} else {
parts.push(`Session ${sessionId} exited with code ${info.exitCode} (${duration}).`);
}
if (info.completionOutput && info.completionOutput.totalLines > 0) {
parts.push(` ${info.completionOutput.totalLines} lines of output.`);
}
if (info.completionOutput && info.completionOutput.lines.length > 0) {
const allLines = info.completionOutput.lines;
let end = allLines.length;
while (end > 0 && allLines[end - 1].trim() === "") end--;
const tail = allLines.slice(Math.max(0, end - BRIEF_TAIL_LINES), end);
if (tail.length > 0) {
parts.push(`\n\n${tail.join("\n")}`);
}
}
parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
return parts.join("");
}
function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
const parts: string[] = [];
if (result.timedOut) {
parts.push(`Session ${sessionId} timed out.`);
} else if (result.cancelled) {
parts.push(`Session ${sessionId} was killed.`);
} else if (result.exitCode === 0) {
parts.push(`Session ${sessionId} completed successfully.`);
} else {
parts.push(`Session ${sessionId} exited with code ${result.exitCode}.`);
}
if (result.completionOutput && result.completionOutput.lines.length > 0) {
const truncNote = result.completionOutput.truncated
? ` (truncated from ${result.completionOutput.totalLines} total lines)`
: "";
parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
}
return parts.join("");
}
function makeMonitorCompletionCallback(
pi: ExtensionAPI,
id: string,
startTime: number,
): (info: HeadlessCompletionInfo) => void {
return (info) => {
const duration = formatDuration(Date.now() - startTime);
const content = buildDispatchNotification(id, info, duration);
pi.sendMessage({
customType: "interactive-shell-transfer",
content,
display: true,
details: { sessionId: id, duration, ...info },
}, { triggerTurn: true });
pi.events.emit("interactive-shell:transfer", { sessionId: id, ...info });
sessionManager.unregisterActive(id, false);
headlessMonitors.delete(id);
sessionManager.scheduleCleanup(id, 5 * 60 * 1000);
};
}
function registerHeadlessActive(
id: string,
command: string,
reason: string | undefined,
session: PtyTerminalSession,
monitor: HeadlessDispatchMonitor,
startTime: number,
): void {
sessionManager.registerActive({
id,
command,
reason,
write: (data) => session.write(data),
kill: () => {
monitor.dispose();
sessionManager.remove(id);
sessionManager.unregisterActive(id, true);
headlessMonitors.delete(id);
},
background: () => {},
getOutput: (opts) => getHeadlessOutput(session, opts),
getStatus: () => session.exited ? "exited" : "running",
getRuntime: () => Date.now() - startTime,
getResult: () => monitor.getResult(),
onComplete: (cb) => monitor.registerCompleteCallback(cb),
});
}
let bgWidgetCleanup: (() => void) | null = null;
function setupBackgroundWidget(ctx: { ui: { setWidget: Function }; hasUI?: boolean }) {
if (!ctx.hasUI) return;
bgWidgetCleanup?.();
let durationTimer: ReturnType<typeof setInterval> | null = null;
let tuiRef: { requestRender: () => void } | null = null;
const requestRender = () => tuiRef?.requestRender();
const unsubscribe = sessionManager.onChange(() => {
manageDurationTimer();
requestRender();
});
function manageDurationTimer() {
const sessions = sessionManager.list();
const hasRunning = sessions.some((s) => !s.session.exited);
if (hasRunning && !durationTimer) {
durationTimer = setInterval(requestRender, 10_000);
} else if (!hasRunning && durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
}
ctx.ui.setWidget(
"bg-sessions",
(tui: any, theme: any) => {
tuiRef = tui;
return {
render: (width: number) => {
const sessions = sessionManager.list();
if (sessions.length === 0) return [];
const cols = width || tui.terminal?.columns || 120;
const lines: string[] = [];
for (const s of sessions) {
const exited = s.session.exited;
const dot = exited ? theme.fg("dim", "○") : theme.fg("accent", "●");
const id = theme.fg("dim", s.id);
const cmd = s.command.replace(/\s+/g, " ").trim();
const truncCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
const reason = s.reason ? theme.fg("dim", ` · ${s.reason}`) : "";
const status = exited ? theme.fg("dim", "exited") : theme.fg("success", "running");
const duration = theme.fg("dim", formatDuration(Date.now() - s.startedAt.getTime()));
const oneLine = ` ${dot} ${id} ${truncCmd}${reason} ${status} ${duration}`;
if (visibleWidth(oneLine) <= cols) {
lines.push(oneLine);
} else {
lines.push(truncateToWidth(` ${dot} ${id} ${cmd}`, cols, "…"));
lines.push(truncateToWidth(` ${status} ${duration}${reason}`, cols, "…"));
}
}
return lines;
},
invalidate: () => {},
};
},
{ placement: "belowEditor" },
);
manageDurationTimer();
bgWidgetCleanup = () => {
unsubscribe();
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
ctx.ui.setWidget("bg-sessions", undefined);
bgWidgetCleanup = null;
};
}
export default function interactiveShellExtension(pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => setupBackgroundWidget(ctx));
pi.on("session_switch", (_event, ctx) => setupBackgroundWidget(ctx));
pi.on("session_shutdown", () => {
bgWidgetCleanup?.();
sessionManager.killAll();
for (const [id, monitor] of headlessMonitors) {
monitor.dispose();
headlessMonitors.delete(id);
}
});
pi.registerTool({
name: TOOL_NAME,
label: TOOL_LABEL,
description: TOOL_DESCRIPTION,
parameters: toolParameters,
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const {
command,
sessionId,
kill,
outputLines,
outputMaxChars,
outputOffset,
drain,
incremental,
settings,
input,
inputKeys,
inputHex,
inputPaste,
cwd,
name,
reason,
mode,
background,
attach,
listBackground,
dismissBackground,
handsFree,
handoffPreview,
handoffSnapshot,
timeout,
} = params as ToolParams;
const hasStructuredInput = inputKeys?.length || inputHex?.length || inputPaste;
const effectiveInput = hasStructuredInput
? { text: input, keys: inputKeys, hex: inputHex, paste: inputPaste }
: input;
// ── Branch 1: Interact with existing session ──
if (sessionId) {
const session = sessionManager.getActive(sessionId);
if (!session) {
return {
content: [{ type: "text", text: `Session not found or no longer active: ${sessionId}` }],
isError: true,
details: { sessionId, error: "session_not_found" },
};
}
// Kill
if (kill) {
const hMonitor = headlessMonitors.get(sessionId);
if (!hMonitor || hMonitor.disposed) {
agentHandledCompletion = true;
}
const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
const status = session.getStatus();
const runtime = session.getRuntime();
session.kill();
sessionManager.unregisterActive(sessionId, true);
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
const hasMoreNote = hasMore === true ? " (more available)" : "";
return {
content: [{ type: "text", text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
details: { sessionId, status: "killed", runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, previousStatus: status },
};
}
// Background
if (background) {
if (session.getResult()) {
return {
content: [{ type: "text", text: "Session already completed." }],
details: session.getResult(),
};
}
const bMonitor = headlessMonitors.get(sessionId);
if (!bMonitor || bMonitor.disposed) {
agentHandledCompletion = true;
}
session.background();
const result = session.getResult();
if (!result || !result.backgrounded) {
agentHandledCompletion = false;
return {
content: [{ type: "text", text: `Session ${sessionId} is already running in the background.` }],
details: { sessionId },
};
}
sessionManager.unregisterActive(sessionId, false);
return {
content: [{ type: "text", text: `Session backgrounded (id: ${result.backgroundId})` }],
details: { sessionId, backgroundId: result.backgroundId, ...result },
};
}
const actions: string[] = [];
if (settings?.updateInterval !== undefined) {
if (sessionManager.setActiveUpdateInterval(sessionId, settings.updateInterval)) {
actions.push(`update interval set to ${settings.updateInterval}ms`);
}
}
if (settings?.quietThreshold !== undefined) {
if (sessionManager.setActiveQuietThreshold(sessionId, settings.quietThreshold)) {
actions.push(`quiet threshold set to ${settings.quietThreshold}ms`);
}
}
if (effectiveInput !== undefined) {
const translatedInput = translateInput(effectiveInput);
const success = sessionManager.writeToActive(sessionId, translatedInput);
if (!success) {
return {
content: [{ type: "text", text: `Failed to send input to session: ${sessionId}` }],
isError: true,
details: { sessionId, error: "write_failed" },
};
}
const inputDesc = typeof effectiveInput === "string"
? effectiveInput.length === 0 ? "(empty)" : effectiveInput.length > 50 ? `${effectiveInput.slice(0, 50)}...` : effectiveInput
: [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)";
actions.push(`sent: ${inputDesc}`);
}
if (actions.length === 0) {
const status = session.getStatus();
const runtime = session.getRuntime();
const result = session.getResult();
if (result) {
const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
const hasOutput = output.length > 0;
const hasMoreNote = hasMore === true ? " (more available)" : "";
sessionManager.unregisterActive(sessionId, !result.backgrounded);
return {
content: [{ type: "text", text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
details: { sessionId, status, runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, exitCode: result.exitCode, signal: result.signal, backgroundId: result.backgroundId },
};
}
const outputResult = session.getOutput({ lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
if (outputResult.rateLimited && outputResult.waitSeconds) {
const waitMs = outputResult.waitSeconds * 1000;
const completedEarly = await Promise.race([
new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
]);
if (completedEarly) {
const earlySession = sessionManager.getActive(sessionId);
if (!earlySession) {
return { content: [{ type: "text", text: `Session ${sessionId} ended` }], details: { sessionId, status: "ended" } };
}
const earlyResult = earlySession.getResult();
const { output, truncated, totalBytes, totalLines, hasMore } = earlySession.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
const earlyStatus = earlySession.getStatus();
const earlyRuntime = earlySession.getRuntime();
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
const hasOutput = output.length > 0;
const hasMoreNote = hasMore === true ? " (more available)" : "";
if (earlyResult) {
sessionManager.unregisterActive(sessionId, !earlyResult.backgrounded);
return {
content: [{ type: "text", text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
details: { sessionId, status: earlyStatus, runtime: earlyRuntime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, exitCode: earlyResult.exitCode, signal: earlyResult.signal, backgroundId: earlyResult.backgroundId },
};
}
return {
content: [{ type: "text", text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
details: { sessionId, status: earlyStatus, runtime: earlyRuntime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, hasOutput },
};
}
const freshOutput = session.getOutput({ lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
const hasOutput = freshOutput.output.length > 0;
const hasMoreNote = freshOutput.hasMore === true ? " (more available)" : "";
const freshStatus = session.getStatus();
const freshRuntime = session.getRuntime();
const freshResult = session.getResult();
if (freshResult) {
sessionManager.unregisterActive(sessionId, !freshResult.backgrounded);
return {
content: [{ type: "text", text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}` }],
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 },
};
}
return {
content: [{ type: "text", text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}` }],
details: { sessionId, status: freshStatus, runtime: freshRuntime, output: freshOutput.output, outputTruncated: freshOutput.truncated, outputTotalBytes: freshOutput.totalBytes, outputTotalLines: freshOutput.totalLines, hasMore: freshOutput.hasMore, hasOutput },
};
}
const { output, truncated, totalBytes, totalLines, hasMore } = outputResult;
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
const hasOutput = output.length > 0;
const hasMoreNote = hasMore === true ? " (more available)" : "";
return {
content: [{ type: "text", text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
details: { sessionId, status, runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, hasOutput },
};
}
return {
content: [{ type: "text", text: `Session ${sessionId}: ${actions.join(", ")}` }],
details: { sessionId, actions },
};
}
// ── Branch 2: Attach to background session ──
if (attach) {
if (background) {
return {
content: [{ type: "text", text: "Cannot attach and background simultaneously." }],
isError: true,
};
}
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Attach requires interactive TUI mode" }],
isError: true,
};
}
if (overlayOpen) {
return {
content: [{ type: "text", text: "An interactive shell overlay is already open." }],
isError: true,
details: { error: "overlay_already_open" },
};
}
const bgSession = sessionManager.take(attach);
if (!bgSession) {
return {
content: [{ type: "text", text: `Background session not found: ${attach}` }],
isError: true,
};
}
const config = loadConfig(cwd ?? ctx.cwd);
const reattachSessionId = attach;
const monitor = headlessMonitors.get(attach);
const isNonBlocking = mode === "hands-free" || mode === "dispatch";
overlayOpen = true;
const attachStartTime = Date.now();
const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
(tui, theme, _kb, done) =>
new InteractiveShellOverlay(tui, theme, {
command: bgSession.command,
existingSession: bgSession.session,
sessionId: reattachSessionId,
mode,
cwd: cwd ?? ctx.cwd,
name: bgSession.name,
reason: bgSession.reason ?? reason,
handsFreeUpdateMode: handsFree?.updateMode,
handsFreeUpdateInterval: handsFree?.updateInterval,
handsFreeQuietThreshold: handsFree?.quietThreshold,
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
autoExitOnQuiet: mode === "dispatch"
? handsFree?.autoExitOnQuiet !== false
: handsFree?.autoExitOnQuiet === true,
handoffPreviewEnabled: handoffPreview?.enabled,
handoffPreviewLines: handoffPreview?.lines,
handoffPreviewMaxChars: handoffPreview?.maxChars,
handoffSnapshotEnabled: handoffSnapshot?.enabled,
handoffSnapshotLines: handoffSnapshot?.lines,
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
timeout,
}, config, done),
{
overlay: true,
overlayOptions: {
width: `${config.overlayWidthPercent}%`,
maxHeight: `${config.overlayHeightPercent}%`,
anchor: "center",
margin: 1,
},
},
);
if (isNonBlocking) {
setupDispatchCompletion(pi, overlayPromise, config, {
id: reattachSessionId,
mode: mode!,
command: bgSession.command,
reason: bgSession.reason,
timeout,
handsFree,
overlayStartTime: attachStartTime,
});
return {
content: [{ type: "text", text: mode === "dispatch"
? `Reattached to ${reattachSessionId}. You'll be notified when it completes.`
: `Reattached to ${reattachSessionId}.\nUse interactive_shell({ sessionId: "${reattachSessionId}" }) to check status/output.` }],
details: { sessionId: reattachSessionId, status: "running", command: bgSession.command, reason: bgSession.reason, mode },
};
}
// Blocking (interactive) attach
let result: InteractiveShellResult;
try {
result = await overlayPromise;
} finally {
overlayOpen = false;
}
if (monitor) {
monitor.dispose();
headlessMonitors.delete(attach);
sessionManager.unregisterActive(attach, !result.backgrounded);
} else if (!result.backgrounded) {
releaseSessionId(attach);
}
let summary: string;
if (result.transferred) {
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
} else if (result.backgrounded) {
summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
} else if (result.cancelled) {
summary = "Session killed";
} else if (result.timedOut) {
summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
} else {
const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
summary = `Session ended ${status}`;
}
if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
}
return { content: [{ type: "text", text: summary }], details: result };
}
// ── Branch 3: List background sessions ──
if (listBackground) {
const sessions = sessionManager.list();
if (sessions.length === 0) {
return { content: [{ type: "text", text: "No background sessions." }] };
}
const lines = sessions.map(s => {
const status = s.session.exited ? "exited" : "running";
const duration = formatDuration(Date.now() - s.startedAt.getTime());
const r = s.reason ? ` \u2022 ${s.reason}` : "";
return ` ${s.id} - ${s.command}${r} (${status}, ${duration})`;
});
return { content: [{ type: "text", text: `Background sessions:\n${lines.join("\n")}` }] };
}
// ── Branch 3b: Dismiss background sessions ──
if (dismissBackground) {
if (typeof dismissBackground === "string") {
if (!sessionManager.list().some(s => s.id === dismissBackground)) {
return { content: [{ type: "text", text: `Background session not found: ${dismissBackground}` }], isError: true };
}
}
const targetIds = typeof dismissBackground === "string"
? [dismissBackground]
: sessionManager.list().map(s => s.id);
if (targetIds.length === 0) {
return { content: [{ type: "text", text: "No background sessions to dismiss." }] };
}
for (const tid of targetIds) {
const monitor = headlessMonitors.get(tid);
if (monitor) {
monitor.dispose();
headlessMonitors.delete(tid);
}
sessionManager.unregisterActive(tid, false);
sessionManager.remove(tid);
}
const summary = targetIds.length === 1
? `Dismissed session ${targetIds[0]}.`
: `Dismissed ${targetIds.length} sessions: ${targetIds.join(", ")}.`;
return { content: [{ type: "text", text: summary }] };
}
// ── Branch 4: Start new session ──
if (!command) {
return {
content: [{ type: "text", text: "One of 'command', 'sessionId', 'attach', 'listBackground', or 'dismissBackground' is required." }],
isError: true,
};
}
const effectiveCwd = cwd ?? ctx.cwd;
const config = loadConfig(effectiveCwd);
const isNonBlocking = mode === "hands-free" || mode === "dispatch";
// ── Branch 4a: Headless dispatch ──
if (mode === "dispatch" && background) {
const id = generateSessionId(name);
const session = new PtyTerminalSession(
{ command, cwd: effectiveCwd, cols: 120, rows: 40, scrollback: config.scrollbackLines },
);
sessionManager.add(command, session, name, reason, { id, noAutoCleanup: true });
const startTime = Date.now();
const monitor = new HeadlessDispatchMonitor(session, config, {
autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
quietThreshold: handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
timeout,
}, makeMonitorCompletionCallback(pi, id, startTime));
headlessMonitors.set(id, monitor);
registerHeadlessActive(id, command, reason, session, monitor, startTime);
return {
content: [{ type: "text", text: `Session dispatched in background (id: ${id}).\nYou'll be notified when it completes. User can /attach ${id} to watch.` }],
details: { sessionId: id, backgroundId: id, mode: "dispatch", background: true },
};
}
// Validate: background only valid with dispatch for new sessions
if (background) {
return {
content: [{ type: "text", text: "background: true requires mode='dispatch' for new sessions." }],
isError: true,
};
}
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Interactive shell requires interactive TUI mode" }],
isError: true,
};
}
if (overlayOpen) {
return {
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." }],
isError: true,
details: { error: "overlay_already_open" },
};
}
const generatedSessionId = isNonBlocking ? generateSessionId(name) : undefined;
// ── Non-blocking path (hands-free or dispatch) ──
if (isNonBlocking && generatedSessionId) {
overlayOpen = true;
const overlayStartTime = Date.now();
const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
(tui, theme, _kb, done) =>
new InteractiveShellOverlay(tui, theme, {
command,
cwd: effectiveCwd,
name,
reason,
mode,
sessionId: generatedSessionId,
handsFreeUpdateMode: handsFree?.updateMode,
handsFreeUpdateInterval: handsFree?.updateInterval,
handsFreeQuietThreshold: handsFree?.quietThreshold,
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
autoExitOnQuiet: mode === "dispatch"
? handsFree?.autoExitOnQuiet !== false
: handsFree?.autoExitOnQuiet === true,
handoffPreviewEnabled: handoffPreview?.enabled,
handoffPreviewLines: handoffPreview?.lines,
handoffPreviewMaxChars: handoffPreview?.maxChars,
handoffSnapshotEnabled: handoffSnapshot?.enabled,
handoffSnapshotLines: handoffSnapshot?.lines,
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
timeout,
}, config, done),
{
overlay: true,
overlayOptions: {
width: `${config.overlayWidthPercent}%`,
maxHeight: `${config.overlayHeightPercent}%`,
anchor: "center",
margin: 1,
},
},
);
setupDispatchCompletion(pi, overlayPromise, config, {
id: generatedSessionId,
mode: mode!,
command,
reason,
timeout,
handsFree,
overlayStartTime,
});
if (mode === "dispatch") {
return {
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.` }],
details: { sessionId: generatedSessionId, status: "running", command, reason, mode },
};
}
return {
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.` }],
details: { sessionId: generatedSessionId, status: "running", command, reason },
};
}
// ── Blocking (interactive) path ──
overlayOpen = true;
onUpdate?.({
content: [{ type: "text", text: `Opening: ${command}` }],
details: { exitCode: null, backgrounded: false, cancelled: false },
});
let result: InteractiveShellResult;
try {
result = await ctx.ui.custom<InteractiveShellResult>(
(tui, theme, _kb, done) =>
new InteractiveShellOverlay(tui, theme, {
command,
cwd: effectiveCwd,
name,
reason,
mode,
sessionId: generatedSessionId,
handsFreeUpdateMode: handsFree?.updateMode,
handsFreeUpdateInterval: handsFree?.updateInterval,
handsFreeQuietThreshold: handsFree?.quietThreshold,
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
autoExitOnQuiet: handsFree?.autoExitOnQuiet,
onHandsFreeUpdate: mode === "hands-free"
? (update) => {
let statusText: string;
switch (update.status) {
case "user-takeover":
statusText = `User took over session ${update.sessionId}`;
break;
case "exited":
statusText = `Session ${update.sessionId} exited`;
break;
default: {
const budgetInfo = update.budgetExhausted ? " [budget exhausted]" : "";
statusText = `Session ${update.sessionId} running (${formatDurationMs(update.runtime)})${budgetInfo}`;
}
}
const newOutput = update.status === "running" && update.tail.length > 0
? `\n\n${update.tail.join("\n")}`
: "";
onUpdate?.({
content: [{ type: "text", text: statusText + newOutput }],
details: {
status: update.status,
sessionId: update.sessionId,
runtime: update.runtime,
newChars: update.tail.join("\n").length,
totalCharsSent: update.totalCharsSent,
budgetExhausted: update.budgetExhausted,
userTookOver: update.userTookOver,
},
});
}
: undefined,
handoffPreviewEnabled: handoffPreview?.enabled,
handoffPreviewLines: handoffPreview?.lines,
handoffPreviewMaxChars: handoffPreview?.maxChars,
handoffSnapshotEnabled: handoffSnapshot?.enabled,
handoffSnapshotLines: handoffSnapshot?.lines,
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
timeout,
}, config, done),
{
overlay: true,
overlayOptions: {
width: `${config.overlayWidthPercent}%`,
maxHeight: `${config.overlayHeightPercent}%`,
anchor: "center",
margin: 1,
},
},
);
} finally {
overlayOpen = false;
}
let summary: string;
if (result.transferred) {
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
summary = `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
} else if (result.backgrounded) {
summary = `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
} else if (result.cancelled) {
summary = "User killed the interactive session";
} else if (result.timedOut) {
summary = `Session killed after timeout (${timeout ?? "?"}ms)`;
} else {
const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
summary = `Session ended ${status}`;
}
if (result.userTookOver) {
summary += "\n\nNote: User took over control during hands-free mode.";
}
const warning = buildIdlePromptWarning(command, reason);
if (warning) {
summary += `\n\n${warning}`;
}
if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
}
return { content: [{ type: "text", text: summary }], details: result };
},
});
pi.registerCommand("attach", {
description: "Reattach to a background shell session",
handler: async (args, ctx) => {
if (overlayOpen) {
ctx.ui.notify("An overlay is already open. Close it first.", "error");
return;
}
const sessions = sessionManager.list();
if (sessions.length === 0) {
ctx.ui.notify("No background sessions", "info");
return;
}
let targetId = args.trim();
if (!targetId) {
const options = sessions.map((s) => {
const status = s.session.exited ? "exited" : "running";
const duration = formatDuration(Date.now() - s.startedAt.getTime());
const sanitizedCommand = s.command.replace(/\s+/g, " ").trim();
const sanitizedReason = s.reason?.replace(/\s+/g, " ").trim();
const r = sanitizedReason ? ` \u2022 ${sanitizedReason}` : "";
return `${s.id} - ${sanitizedCommand}${r} (${status}, ${duration})`;
});
const choice = await ctx.ui.select("Background Sessions", options);
if (!choice) return;
targetId = choice.split(" - ")[0]!;
}
const monitor = headlessMonitors.get(targetId);
const session = sessionManager.get(targetId);
if (!session) {
ctx.ui.notify(`Session not found: ${targetId}`, "error");
return;
}
const config = loadConfig(ctx.cwd);
overlayOpen = true;
try {
const result = await ctx.ui.custom<InteractiveShellResult>(
(tui, theme, _kb, done) =>
new ReattachOverlay(tui, theme, { id: session.id, command: session.command, reason: session.reason, session: session.session }, config, done),
{
overlay: true,
overlayOptions: {
width: `${config.overlayWidthPercent}%`,
maxHeight: `${config.overlayHeightPercent}%`,
anchor: "center",
margin: 1,
},
},
);
if (monitor && !monitor.disposed) {
if (!result.backgrounded) {
monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
headlessMonitors.delete(targetId);
}
} else if (result.backgrounded) {
sessionManager.restartAutoCleanup(targetId);
} else {
sessionManager.scheduleCleanup(targetId);
}
} finally {
overlayOpen = false;
}
},
});
pi.registerCommand("dismiss", {
description: "Dismiss background shell sessions (kill running, remove exited)",
handler: async (args, ctx) => {
const sessions = sessionManager.list();
if (sessions.length === 0) {
ctx.ui.notify("No background sessions", "info");
return;
}
let targetIds: string[];
const arg = args.trim();
if (arg) {
if (!sessions.some(s => s.id === arg)) {
ctx.ui.notify(`Session not found: ${arg}`, "error");
return;
}
targetIds = [arg];
} else if (sessions.length === 1) {
targetIds = [sessions[0].id];
} else {
const options = ["All sessions", ...sessions.map((s) => {
const status = s.session.exited ? "exited" : "running";
const duration = formatDuration(Date.now() - s.startedAt.getTime());
return `${s.id} (${status}, ${duration})`;
})];
const choice = await ctx.ui.select("Dismiss sessions", options);
if (!choice) return;
targetIds = choice === "All sessions"
? sessions.map(s => s.id)
: [choice.split(" (")[0]];
}
for (const tid of targetIds) {
const monitor = headlessMonitors.get(tid);
if (monitor) {
monitor.dispose();
headlessMonitors.delete(tid);
}
sessionManager.unregisterActive(tid, false);
sessionManager.remove(tid);
}
const noun = targetIds.length === 1 ? "session" : "sessions";
ctx.ui.notify(`Dismissed ${targetIds.length} ${noun}`, "info");
},
});
}
function setupDispatchCompletion(
pi: ExtensionAPI,
overlayPromise: Promise<InteractiveShellResult>,
config: InteractiveShellConfig,
ctx: {
id: string;
mode: string;
command: string;
reason?: string;
timeout?: number;
handsFree?: { autoExitOnQuiet?: boolean; quietThreshold?: number };
overlayStartTime?: number;
},
): void {
const { id, mode, command, reason } = ctx;
overlayPromise.then((result) => {
overlayOpen = false;
const wasAgentInitiated = agentHandledCompletion;
agentHandledCompletion = false;
if (result.transferred) {
const truncatedNote = result.transferred.truncated
? ` (truncated from ${result.transferred.totalLines} total lines)`
: "";
const content = `Session ${id} output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
pi.sendMessage({
customType: "interactive-shell-transfer",
content,
display: true,
details: { sessionId: id, transferred: result.transferred, exitCode: result.exitCode, signal: result.signal },
}, { triggerTurn: true });
pi.events.emit("interactive-shell:transfer", { sessionId: id, transferred: result.transferred, exitCode: result.exitCode, signal: result.signal });
sessionManager.unregisterActive(id, true);
const remainingMonitor = headlessMonitors.get(id);
if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
} else if (mode === "dispatch" && result.backgrounded) {
if (!wasAgentInitiated) {
pi.sendMessage({
customType: "interactive-shell-transfer",
content: `Session ${id} moved to background (id: ${result.backgroundId}).`,
display: true,
details: { sessionId: id, backgroundId: result.backgroundId },
}, { triggerTurn: true });
}
sessionManager.unregisterActive(id, false);
const existingMonitor = headlessMonitors.get(id);
if (existingMonitor && !existingMonitor.disposed) {
const bgSession = sessionManager.get(result.backgroundId!);
if (bgSession) {
registerHeadlessActive(result.backgroundId!, command, reason, bgSession.session, existingMonitor, existingMonitor.startTime);
}
} else if (!existingMonitor) {
const bgSession = sessionManager.get(result.backgroundId!);
if (bgSession) {
const bgId = result.backgroundId!;
const bgStartTime = ctx.overlayStartTime ?? Date.now();
const elapsed = ctx.overlayStartTime ? Date.now() - ctx.overlayStartTime : 0;
const remainingTimeout = ctx.timeout ? Math.max(0, ctx.timeout - elapsed) : undefined;
const monitor = new HeadlessDispatchMonitor(bgSession.session, config, {
autoExitOnQuiet: ctx.handsFree?.autoExitOnQuiet !== false,
quietThreshold: ctx.handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
timeout: remainingTimeout,
}, makeMonitorCompletionCallback(pi, bgId, bgStartTime));
headlessMonitors.set(bgId, monitor);
registerHeadlessActive(bgId, command, reason, bgSession.session, monitor, bgStartTime);
}
}
} else if (mode === "dispatch") {
if (!wasAgentInitiated) {
const content = buildResultNotification(id, result);
pi.sendMessage({
customType: "interactive-shell-transfer",
content,
display: true,
details: { sessionId: id, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, cancelled: result.cancelled, completionOutput: result.completionOutput },
}, { triggerTurn: true });
}
pi.events.emit("interactive-shell:transfer", {
sessionId: id,
completionOutput: result.completionOutput,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
cancelled: result.cancelled,
});
sessionManager.unregisterActive(id, true);
const remainingMonitor = headlessMonitors.get(id);
if (remainingMonitor) { remainingMonitor.dispose(); headlessMonitors.delete(id); }
}
if (mode !== "dispatch") {
const staleMonitor = headlessMonitors.get(id);
if (staleMonitor) { staleMonitor.dispose(); headlessMonitors.delete(id); }
}
}).catch(() => {
overlayOpen = false;
sessionManager.unregisterActive(id, true);
const orphanedMonitor = headlessMonitors.get(id);
if (orphanedMonitor) { orphanedMonitor.dispose(); headlessMonitors.delete(id); }
});
}
function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
if (!reason) return null;
const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
if (!tasky.test(reason)) return null;
const trimmed = command.trim();
const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
const bin = binaries.find((b) => trimmed === b || trimmed.startsWith(`${b} `));
if (!bin) return null;
const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
const hasQuotedPrompt = /["']/.test(rest);
const hasKnownPromptFlag =
/\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
(bin === "pi" && /\b-p\b/.test(rest)) ||
(bin === "codex" && /\bexec\b/.test(rest));
if (hasQuotedPrompt || hasKnownPromptFlag) return null;
if (rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest)) {
const examplePrompt = reason.replace(/\s+/g, " ").trim();
const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
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}"\`.`;
}
return null;
}
/**
* Terminal key encoding utilities for translating named keys and modifiers
* into terminal escape sequences.
*/
// Named key sequences (without modifiers)
const NAMED_KEYS: Record<string, string> = {
// Arrow keys
up: "\x1b[A",
down: "\x1b[B",
left: "\x1b[D",
right: "\x1b[C",
// Common keys
enter: "\r",
return: "\r",
escape: "\x1b",
esc: "\x1b",
tab: "\t",
space: " ",
backspace: "\x7f",
bspace: "\x7f", // tmux-style alias
// Editing keys
delete: "\x1b[3~",
del: "\x1b[3~",
dc: "\x1b[3~", // tmux-style alias
insert: "\x1b[2~",
ic: "\x1b[2~", // tmux-style alias
// Navigation
home: "\x1b[H",
end: "\x1b[F",
pageup: "\x1b[5~",
pgup: "\x1b[5~",
ppage: "\x1b[5~", // tmux-style alias
pagedown: "\x1b[6~",
pgdn: "\x1b[6~",
npage: "\x1b[6~", // tmux-style alias
// Shift+Tab (backtab)
btab: "\x1b[Z",
// Function keys
f1: "\x1bOP",
f2: "\x1bOQ",
f3: "\x1bOR",
f4: "\x1bOS",
f5: "\x1b[15~",
f6: "\x1b[17~",
f7: "\x1b[18~",
f8: "\x1b[19~",
f9: "\x1b[20~",
f10: "\x1b[21~",
f11: "\x1b[23~",
f12: "\x1b[24~",
// Keypad keys (application mode)
kp0: "\x1bOp",
kp1: "\x1bOq",
kp2: "\x1bOr",
kp3: "\x1bOs",
kp4: "\x1bOt",
kp5: "\x1bOu",
kp6: "\x1bOv",
kp7: "\x1bOw",
kp8: "\x1bOx",
kp9: "\x1bOy",
"kp/": "\x1bOo",
"kp*": "\x1bOj",
"kp-": "\x1bOm",
"kp+": "\x1bOk",
"kp.": "\x1bOn",
kpenter: "\x1bOM",
};
// Ctrl+key combinations (ctrl+a through ctrl+z, plus some special)
const CTRL_KEYS: Record<string, string> = {};
for (let i = 0; i < 26; i++) {
const char = String.fromCharCode(97 + i); // a-z
CTRL_KEYS[`ctrl+${char}`] = String.fromCharCode(i + 1);
}
// Special ctrl combinations
CTRL_KEYS["ctrl+["] = "\x1b"; // Same as Escape
CTRL_KEYS["ctrl+\\"] = "\x1c";
CTRL_KEYS["ctrl+]"] = "\x1d";
CTRL_KEYS["ctrl+^"] = "\x1e";
CTRL_KEYS["ctrl+_"] = "\x1f";
CTRL_KEYS["ctrl+?"] = "\x7f"; // Same as Backspace
// Alt+key sends ESC followed by the key
function altKey(char: string): string {
return `\x1b${char}`;
}
// Keys that support xterm modifier encoding (CSI sequences)
const MODIFIABLE_KEYS = new Set([
"up", "down", "left", "right", "home", "end",
"pageup", "pgup", "ppage", "pagedown", "pgdn", "npage",
"insert", "ic", "delete", "del", "dc",
]);
// Calculate xterm modifier code: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0)
function xtermModifier(shift: boolean, alt: boolean, ctrl: boolean): number {
let mod = 1;
if (shift) mod += 1;
if (alt) mod += 2;
if (ctrl) mod += 4;
return mod;
}
// Apply xterm modifier to CSI sequence: ESC[A -> ESC[1;modA
function applyXtermModifier(sequence: string, modifier: number): string | null {
// Arrow keys: ESC[A -> ESC[1;modA
const arrowMatch = sequence.match(/^\x1b\[([A-D])$/);
if (arrowMatch) {
return `\x1b[1;${modifier}${arrowMatch[1]}`;
}
// Numbered sequences: ESC[5~ -> ESC[5;mod~
const numMatch = sequence.match(/^\x1b\[(\d+)~$/);
if (numMatch) {
return `\x1b[${numMatch[1]};${modifier}~`;
}
// Home/End: ESC[H -> ESC[1;modH, ESC[F -> ESC[1;modF
const hfMatch = sequence.match(/^\x1b\[([HF])$/);
if (hfMatch) {
return `\x1b[1;${modifier}${hfMatch[1]}`;
}
return null;
}
// Bracketed paste mode sequences
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
function encodePaste(text: string, bracketed = true): string {
if (!bracketed) return text;
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
}
/** Parse a key token and return the escape sequence */
function encodeKeyToken(token: string): string {
const normalized = token.trim().toLowerCase();
if (!normalized) return "";
// Check for direct match in named keys
if (NAMED_KEYS[normalized]) {
return NAMED_KEYS[normalized];
}
// Check for ctrl+key
if (CTRL_KEYS[normalized]) {
return CTRL_KEYS[normalized];
}
// Parse modifier prefixes: ctrl+alt+shift+key, c-m-s-key, etc.
let rest = normalized;
let ctrl = false, alt = false, shift = false;
// Support both "ctrl+alt+x" and "c-m-x" syntax
while (rest.length > 2) {
if (rest.startsWith("ctrl+") || rest.startsWith("ctrl-")) {
ctrl = true;
rest = rest.slice(5);
} else if (rest.startsWith("alt+") || rest.startsWith("alt-")) {
alt = true;
rest = rest.slice(4);
} else if (rest.startsWith("shift+") || rest.startsWith("shift-")) {
shift = true;
rest = rest.slice(6);
} else if (rest.startsWith("c-")) {
ctrl = true;
rest = rest.slice(2);
} else if (rest.startsWith("m-")) {
alt = true;
rest = rest.slice(2);
} else if (rest.startsWith("s-")) {
shift = true;
rest = rest.slice(2);
} else {
break;
}
}
// Handle shift+tab specially
if (shift && rest === "tab") {
return "\x1b[Z";
}
// Check if base key is a named key that supports modifiers
const baseSeq = NAMED_KEYS[rest];
if (baseSeq && MODIFIABLE_KEYS.has(rest) && (ctrl || alt || shift)) {
const mod = xtermModifier(shift, alt, ctrl);
if (mod > 1) {
const modified = applyXtermModifier(baseSeq, mod);
if (modified) return modified;
}
}
// For single character with modifiers
if (rest.length === 1) {
let char = rest;
if (shift && /[a-z]/.test(char)) {
char = char.toUpperCase();
}
if (ctrl) {
const ctrlChar = CTRL_KEYS[`ctrl+${char.toLowerCase()}`];
if (ctrlChar) char = ctrlChar;
}
if (alt) {
return altKey(char);
}
return char;
}
// Named key with alt modifier
if (baseSeq && alt) {
return `\x1b${baseSeq}`;
}
// Return base sequence if found
if (baseSeq) {
return baseSeq;
}
// Unknown key, return as literal
return token;
}
/** Translate input specification to terminal escape sequences */
export function translateInput(input: string | { text?: string; keys?: string[]; paste?: string; hex?: string[] }): string {
if (typeof input === "string") {
return input;
}
let result = "";
// Hex bytes (raw escape sequences)
if (input.hex?.length) {
for (const raw of input.hex) {
const trimmed = raw.trim().toLowerCase();
const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
if (/^[0-9a-f]{1,2}$/.test(normalized)) {
const value = Number.parseInt(normalized, 16);
if (!Number.isNaN(value) && value >= 0 && value <= 0xff) {
result += String.fromCharCode(value);
}
}
}
}
// Literal text
if (input.text) {
result += input.text;
}
// Named keys with modifier support
if (input.keys) {
for (const key of input.keys) {
result += encodeKeyToken(key);
}
}
// Bracketed paste
if (input.paste) {
result += encodePaste(input.paste);
}
return result;
}
{
"name": "pi-interactive-shell",
"version": "0.7.1",
"description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
"type": "module",
"bin": {
"pi-interactive-shell": "./scripts/install.js"
},
"files": [
"index.ts",
"config.ts",
"key-encoding.ts",
"overlay-component.ts",
"pty-session.ts",
"reattach-overlay.ts",
"session-manager.ts",
"tool-schema.ts",
"headless-monitor.ts",
"types.ts",
"scripts/",
"banner.png",
"README.md",
"SKILL.md",
"CHANGELOG.md"
],
"pi": {
"extensions": [
"./index.ts"
],
"video": "https://github.com/nicobailon/pi-interactive-shell/raw/refs/heads/main/pi-interactive-shell-extension.mp4"
},
"dependencies": {
"node-pty": "^1.1.0",
"@xterm/headless": "^5.5.0",
"@xterm/addon-serialize": "^0.13.0"
},
"scripts": {
"postinstall": "node ./scripts/fix-spawn-helper.cjs"
},
"keywords": [
"pi-package",
"pi",
"pi-coding-agent",
"extension",
"interactive",
"shell",
"terminal",
"tui",
"subagent",
"claude",
"gemini",
"codex"
],
"author": "Nico Bailon",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/nicobailon/pi-interactive-shell.git"
},
"bugs": {
"url": "https://github.com/nicobailon/pi-interactive-shell/issues"
},
"homepage": "https://github.com/nicobailon/pi-interactive-shell#readme"
}
import { chmodSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { stripVTControlCharacters } from "node:util";
import * as pty from "node-pty";
import type { IBufferCell, Terminal as XtermTerminal } from "@xterm/headless";
import xterm from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
const Terminal = xterm.Terminal;
const require = createRequire(import.meta.url);
let spawnHelperChecked = false;
// Regex patterns for sanitizing terminal output (used by sanitizeLine for viewport rendering)
const OSC_REGEX = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
const APC_REGEX = /\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g;
const DCS_REGEX = /\x1bP[^\x07\x1b]*(?:\x07|\x1b\\)/g;
const CSI_REGEX = /\x1b\[[0-9;?]*[A-Za-z]/g;
const ESC_SINGLE_REGEX = /\x1b[@-_]/g;
const CONTROL_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g;
// DSR (Device Status Report) - cursor position query: ESC[6n or ESC[?6n
const DSR_PATTERN = /\x1b\[\??6n/g;
// Maximum raw output buffer size (1MB) - prevents unbounded memory growth
const MAX_RAW_OUTPUT_SIZE = 1024 * 1024;
interface DsrSplit {
segments: Array<{ text: string; dsrAfter: boolean }>;
hasDsr: boolean;
}
function splitAroundDsr(input: string): DsrSplit {
const segments: Array<{ text: string; dsrAfter: boolean }> = [];
let lastIndex = 0;
let hasDsr = false;
// Find all DSR requests and split around them
const regex = new RegExp(DSR_PATTERN.source, "g");
let match;
while ((match = regex.exec(input)) !== null) {
hasDsr = true;
// Text before this DSR
if (match.index > lastIndex) {
segments.push({ text: input.slice(lastIndex, match.index), dsrAfter: true });
} else {
// DSR at start or consecutive DSRs - add empty segment to trigger response
segments.push({ text: "", dsrAfter: true });
}
lastIndex = match.index + match[0].length;
}
// Remaining text after last DSR (or entire string if no DSR)
if (lastIndex < input.length) {
segments.push({ text: input.slice(lastIndex), dsrAfter: false });
}
return { segments, hasDsr };
}
function buildCursorPositionResponse(row = 1, col = 1): string {
return `\x1b[${row};${col}R`;
}
function ensureSpawnHelperExec(): void {
if (spawnHelperChecked) return;
spawnHelperChecked = true;
if (process.platform !== "darwin") return;
let pkgPath: string;
try {
pkgPath = require.resolve("node-pty/package.json");
} catch {
return;
}
const base = dirname(pkgPath);
const targets = [
join(base, "prebuilds", "darwin-arm64", "spawn-helper"),
join(base, "prebuilds", "darwin-x64", "spawn-helper"),
];
for (const target of targets) {
try {
const stats = statSync(target);
const mode = stats.mode | 0o111;
if ((stats.mode & 0o111) !== 0o111) {
chmodSync(target, mode);
}
} catch {
continue;
}
}
}
function sanitizeLine(line: string): string {
let out = line;
if (out.includes("\u001b")) {
out = out.replace(OSC_REGEX, "");
out = out.replace(APC_REGEX, "");
out = out.replace(DCS_REGEX, "");
out = out.replace(CSI_REGEX, (match) => (match.endsWith("m") ? match : ""));
out = out.replace(ESC_SINGLE_REGEX, "");
}
if (out.includes("\t")) {
out = out.replace(/\t/g, " ");
}
if (out.includes("\r")) {
out = out.replace(/\r/g, "");
}
out = out.replace(CONTROL_REGEX, "");
return out;
}
type CellStyle = {
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
inverse: boolean;
invisible: boolean;
strikethrough: boolean;
fgMode: "default" | "palette" | "rgb";
fg: number;
bgMode: "default" | "palette" | "rgb";
bg: number;
};
function styleKey(style: CellStyle): string {
return [
style.bold ? "b" : "-",
style.dim ? "d" : "-",
style.italic ? "i" : "-",
style.underline ? "u" : "-",
style.inverse ? "v" : "-",
style.invisible ? "x" : "-",
style.strikethrough ? "s" : "-",
`fg:${style.fgMode}:${style.fg}`,
`bg:${style.bgMode}:${style.bg}`,
].join("");
}
function rgbToSgr(isFg: boolean, hex: number): string {
const r = (hex >> 16) & 0xff;
const g = (hex >> 8) & 0xff;
const b = hex & 0xff;
return isFg ? `38;2;${r};${g};${b}` : `48;2;${r};${g};${b}`;
}
function paletteToSgr(isFg: boolean, idx: number): string {
return isFg ? `38;5;${idx}` : `48;5;${idx}`;
}
function sgrForStyle(style: CellStyle): string {
const parts: string[] = ["0"];
if (style.bold) parts.push("1");
if (style.dim) parts.push("2");
if (style.italic) parts.push("3");
if (style.underline) parts.push("4");
if (style.inverse) parts.push("7");
if (style.invisible) parts.push("8");
if (style.strikethrough) parts.push("9");
if (style.fgMode === "rgb") parts.push(rgbToSgr(true, style.fg));
else if (style.fgMode === "palette") parts.push(paletteToSgr(true, style.fg));
if (style.bgMode === "rgb") parts.push(rgbToSgr(false, style.bg));
else if (style.bgMode === "palette") parts.push(paletteToSgr(false, style.bg));
return `\u001b[${parts.join(";")}m`;
}
function normalizePaletteColor(mode: "default" | "palette" | "rgb", value: number): { mode: "default" | "palette" | "rgb"; value: number } {
if (mode !== "palette") return { mode, value };
// xterm uses special palette values (>= 256) to represent defaults/specials; do not emit invalid 38;5;N codes.
if (value < 0 || value > 255) {
return { mode: "default", value: 0 };
}
return { mode: "palette", value };
}
export interface PtySessionOptions {
command: string;
shell?: string;
cwd?: string;
env?: Record<string, string | undefined>;
cols?: number;
rows?: number;
scrollback?: number;
ansiReemit?: boolean;
}
export interface PtySessionEvents {
onData?: (data: string) => void;
onExit?: (exitCode: number, signal?: number) => void;
}
// Simple write queue to ensure ordered writes to terminal
class WriteQueue {
private queue = Promise.resolve();
enqueue(fn: () => Promise<void> | void): void {
this.queue = this.queue.then(() => fn()).catch((err) => {
console.error("WriteQueue error:", err);
});
}
async drain(): Promise<void> {
await this.queue;
}
}
export class PtyTerminalSession {
private ptyProcess: pty.IPty;
private xterm: XtermTerminal;
private serializer: SerializeAddon | null = null;
private _exited = false;
private _exitCode: number | null = null;
private _signal: number | undefined;
private scrollOffset = 0;
private followBottom = true; // Auto-scroll to bottom when new data arrives
// Raw output buffer for incremental streaming
private rawOutput = "";
private lastStreamPosition = 0;
// Write queue for ordered terminal writes
private writeQueue = new WriteQueue();
private dataHandler: ((data: string) => void) | undefined;
private exitHandler: ((exitCode: number, signal?: number) => void) | undefined;
private additionalDataListeners: Array<(data: string) => void> = [];
private additionalExitListeners: Array<(exitCode: number, signal?: number) => void> = [];
// Trim raw output buffer if it exceeds max size
private trimRawOutputIfNeeded(): void {
if (this.rawOutput.length > MAX_RAW_OUTPUT_SIZE) {
const keepSize = Math.floor(MAX_RAW_OUTPUT_SIZE / 2);
const trimAmount = this.rawOutput.length - keepSize;
this.rawOutput = this.rawOutput.substring(trimAmount);
// Adjust stream position to account for trimmed content
this.lastStreamPosition = Math.max(0, this.lastStreamPosition - trimAmount);
}
}
constructor(options: PtySessionOptions, events: PtySessionEvents = {}) {
const {
command,
cwd = process.cwd(),
env,
cols = 80,
rows = 24,
scrollback = 5000,
ansiReemit = true,
} = options;
this.dataHandler = events.onData;
this.exitHandler = events.onExit;
this.xterm = new Terminal({ cols, rows, scrollback, allowProposedApi: true, convertEol: true });
if (ansiReemit) {
this.serializer = new SerializeAddon();
this.xterm.loadAddon(this.serializer);
}
const shell =
options.shell ??
(process.platform === "win32"
? process.env.COMSPEC || "cmd.exe"
: process.env.SHELL || "/bin/sh");
const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
const mergedEnv = env ? { ...process.env, ...env } : { ...process.env };
if (!mergedEnv.TERM) mergedEnv.TERM = "xterm-256color";
ensureSpawnHelperExec();
this.ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: mergedEnv,
});
this.ptyProcess.onData((data) => {
// Handle DSR (Device Status Report) cursor position queries
// TUI apps send ESC[6n or ESC[?6n expecting ESC[row;colR response
// We must process in order: write text to xterm, THEN respond to DSR
const { segments, hasDsr } = splitAroundDsr(data);
if (!hasDsr) {
// Fast path: no DSR in data
this.writeQueue.enqueue(async () => {
this.rawOutput += data;
this.trimRawOutputIfNeeded();
await new Promise<void>((resolve) => {
this.xterm.write(data, () => resolve());
});
this.notifyDataListeners(data);
});
} else {
// Process each segment in order, responding to DSR after writing preceding text
for (const segment of segments) {
this.writeQueue.enqueue(async () => {
if (segment.text) {
this.rawOutput += segment.text;
this.trimRawOutputIfNeeded();
await new Promise<void>((resolve) => {
this.xterm.write(segment.text, () => resolve());
});
this.notifyDataListeners(segment.text);
}
// If there was a DSR after this segment, respond with current cursor position
if (segment.dsrAfter) {
const buffer = this.xterm.buffer.active;
const response = buildCursorPositionResponse(buffer.cursorY + 1, buffer.cursorX + 1);
this.ptyProcess.write(response);
}
});
}
}
});
this.ptyProcess.onExit(({ exitCode, signal }) => {
this._exited = true;
this._exitCode = exitCode;
this._signal = signal;
// Append exit message to terminal buffer, then notify handler after queue drains
const exitMsg = `\n[Process exited with code ${exitCode}${signal ? ` (signal: ${signal})` : ""}]\n`;
this.writeQueue.enqueue(async () => {
this.rawOutput += exitMsg;
await new Promise<void>((resolve) => {
this.xterm.write(exitMsg, () => resolve());
});
});
// Wait for writeQueue to drain before calling exit listeners
// This ensures exit message is in rawOutput and xterm buffer
this.writeQueue.drain().then(() => {
this.notifyExitListeners(exitCode, signal);
});
});
}
setEventHandlers(events: PtySessionEvents): void {
this.dataHandler = events.onData;
this.exitHandler = events.onExit;
}
addDataListener(cb: (data: string) => void): () => void {
this.additionalDataListeners.push(cb);
return () => {
const idx = this.additionalDataListeners.indexOf(cb);
if (idx >= 0) this.additionalDataListeners.splice(idx, 1);
};
}
addExitListener(cb: (exitCode: number, signal?: number) => void): () => void {
this.additionalExitListeners.push(cb);
return () => {
const idx = this.additionalExitListeners.indexOf(cb);
if (idx >= 0) this.additionalExitListeners.splice(idx, 1);
};
}
private notifyDataListeners(data: string): void {
this.dataHandler?.(data);
for (const listener of this.additionalDataListeners) {
listener(data);
}
}
private notifyExitListeners(exitCode: number, signal?: number): void {
this.exitHandler?.(exitCode, signal);
for (const listener of this.additionalExitListeners) {
listener(exitCode, signal);
}
}
get exited(): boolean {
return this._exited;
}
get exitCode(): number | null {
return this._exitCode;
}
get signal(): number | undefined {
return this._signal;
}
get pid(): number {
return this.ptyProcess.pid;
}
get cols(): number {
return this.xterm.cols;
}
get rows(): number {
return this.xterm.rows;
}
write(data: string): void {
if (!this._exited) {
this.ptyProcess.write(data);
}
}
resize(cols: number, rows: number): void {
if (cols === this.xterm.cols && rows === this.xterm.rows) return;
if (cols < 1 || rows < 1) return;
this.xterm.resize(cols, rows);
if (!this._exited) {
this.ptyProcess.resize(cols, rows);
}
}
private renderLineFromCells(lineIndex: number, cols: number): string {
const buffer = this.xterm.buffer.active;
const line = buffer.getLine(lineIndex);
let currentStyle: CellStyle = {
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
invisible: false,
strikethrough: false,
fgMode: "default",
fg: 0,
bgMode: "default",
bg: 0,
};
let currentKey = styleKey(currentStyle);
let out = sgrForStyle(currentStyle);
for (let x = 0; x < cols; x++) {
const cell: IBufferCell | undefined = line?.getCell(x);
const width = cell?.getWidth() ?? 1;
if (width === 0) continue;
const chars = cell?.getChars() ?? " ";
const cellChars = chars.length === 0 ? " " : chars;
const rawFgMode: CellStyle["fgMode"] = cell?.isFgDefault()
? "default"
: cell?.isFgRGB()
? "rgb"
: cell?.isFgPalette()
? "palette"
: "default";
const rawBgMode: CellStyle["bgMode"] = cell?.isBgDefault()
? "default"
: cell?.isBgRGB()
? "rgb"
: cell?.isBgPalette()
? "palette"
: "default";
const fg = normalizePaletteColor(rawFgMode, cell?.getFgColor() ?? 0);
const bg = normalizePaletteColor(rawBgMode, cell?.getBgColor() ?? 0);
const nextStyle: CellStyle = {
bold: !!cell?.isBold(),
dim: !!cell?.isDim(),
italic: !!cell?.isItalic(),
underline: !!cell?.isUnderline(),
inverse: !!cell?.isInverse(),
invisible: !!cell?.isInvisible(),
strikethrough: !!cell?.isStrikethrough(),
fgMode: fg.mode,
fg: fg.value,
bgMode: bg.mode,
bg: bg.value,
};
const nextKey = styleKey(nextStyle);
if (nextKey !== currentKey) {
currentStyle = nextStyle;
currentKey = nextKey;
out += sgrForStyle(currentStyle);
}
out += cellChars;
}
return out + "\u001b[0m";
}
getViewportLines(options: { ansi?: boolean } = {}): string[] {
const buffer = this.xterm.buffer.active;
const lines: string[] = [];
const totalLines = buffer.length;
// If following bottom, reset scroll offset at render time (not on each data event)
// This prevents flickering from scroll position racing with buffer updates
if (this.followBottom) {
this.scrollOffset = 0;
}
const viewportStart = Math.max(0, totalLines - this.xterm.rows - this.scrollOffset);
const useAnsi = !!options.ansi;
if (useAnsi) {
for (let i = 0; i < this.xterm.rows; i++) {
const lineIndex = viewportStart + i;
const rendered = this.renderLineFromCells(lineIndex, this.xterm.cols);
// Safety fallback: if our cell->SGR renderer produces no visible non-space content
// but the buffer line contains text, fall back to plain translation. This prevents
// “blank screen” regressions on terminals that use special color encodings.
const plain = buffer.getLine(lineIndex)?.translateToString(true) ?? "";
const renderedPlain = rendered
.replace(/\x1b\[[0-9;]*m/g, "")
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
if (plain.trim().length > 0 && renderedPlain.trim().length === 0) {
lines.push(sanitizeLine(plain) + "\u001b[0m");
} else {
lines.push(rendered);
}
}
return lines;
}
for (let i = 0; i < this.xterm.rows; i++) {
const lineIndex = viewportStart + i;
if (lineIndex < totalLines) {
const line = buffer.getLine(lineIndex);
lines.push(sanitizeLine(line?.translateToString(true) ?? ""));
} else {
lines.push("");
}
}
return lines;
}
getTailLines(options: { lines: number; ansi?: boolean; maxChars?: number }): {
lines: string[];
totalLinesInBuffer: number;
truncatedByChars: boolean;
} {
const requested = Math.max(0, Math.trunc(options.lines));
const maxChars = options.maxChars !== undefined ? Math.max(0, Math.trunc(options.maxChars)) : undefined;
const buffer = this.xterm.buffer.active;
const totalLinesInBuffer = buffer.length;
if (requested === 0) {
return { lines: [], totalLinesInBuffer, truncatedByChars: false };
}
const start = Math.max(0, totalLinesInBuffer - requested);
const out: string[] = [];
let remainingChars = maxChars;
let truncatedByChars = false;
const useAnsi = options.ansi && this.serializer;
if (useAnsi) {
const serialized = this.serializer!.serialize();
const serializedLines = serialized.split(/\r?\n/);
if (serializedLines.length >= totalLinesInBuffer) {
for (let i = start; i < totalLinesInBuffer; i++) {
const raw = serializedLines[i] ?? "";
const line = sanitizeLine(raw) + "\u001b[0m";
if (remainingChars !== undefined) {
if (remainingChars <= 0) {
truncatedByChars = true;
break;
}
remainingChars -= line.length;
}
out.push(line);
}
return { lines: out, totalLinesInBuffer, truncatedByChars };
}
}
for (let i = start; i < totalLinesInBuffer; i++) {
const lineObj = buffer.getLine(i);
const line = sanitizeLine(lineObj?.translateToString(true) ?? "");
if (remainingChars !== undefined) {
if (remainingChars <= 0) {
truncatedByChars = true;
break;
}
remainingChars -= line.length;
}
out.push(line);
}
return { lines: out, totalLinesInBuffer, truncatedByChars };
}
/**
* Get raw output stream with optional incremental reading.
* @param options.sinceLast - If true, only return output since last call
* @param options.stripAnsi - If true, strip ANSI escape codes (default: true)
*/
getRawStream(options: { sinceLast?: boolean; stripAnsi?: boolean } = {}): string {
let output: string;
if (options.sinceLast) {
output = this.rawOutput.substring(this.lastStreamPosition);
this.lastStreamPosition = this.rawOutput.length;
} else {
output = this.rawOutput;
}
// Strip ANSI codes and control characters by default using Node.js built-in
if (options.stripAnsi !== false && output) {
output = stripVTControlCharacters(output);
}
return output;
}
/**
* Get a slice of log output with offset/limit pagination.
* Similar to Clawdbot's sliceLogLines - enables reading specific ranges of output.
* @param options.offset - Line number to start from (0-indexed). If omitted with limit, returns tail.
* @param options.limit - Max number of lines to return
* @param options.stripAnsi - If true, strip ANSI escape codes (default: true)
*/
getLogSlice(options: { offset?: number; limit?: number; stripAnsi?: boolean } = {}): {
slice: string;
totalLines: number;
totalChars: number;
sliceLineCount: number;
} {
let text = this.rawOutput;
// Strip ANSI by default
if (options.stripAnsi !== false && text) {
text = stripVTControlCharacters(text);
}
if (!text) {
return { slice: "", totalLines: 0, totalChars: 0, sliceLineCount: 0 };
}
// Normalize line endings and split
const normalized = text.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
// Remove trailing empty line from split
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
const totalLines = lines.length;
const totalChars = text.length;
// Calculate start position
let start: number;
if (typeof options.offset === "number" && Number.isFinite(options.offset)) {
start = Math.max(0, Math.floor(options.offset));
} else if (options.limit !== undefined) {
// No offset but limit provided - return tail (last N lines)
const tailCount = Math.max(0, Math.floor(options.limit));
start = Math.max(totalLines - tailCount, 0);
} else {
start = 0;
}
// Calculate end position
const end = typeof options.limit === "number" && Number.isFinite(options.limit)
? start + Math.max(0, Math.floor(options.limit))
: undefined;
const selectedLines = lines.slice(start, end);
return {
slice: selectedLines.join("\n"),
totalLines,
totalChars,
sliceLineCount: selectedLines.length,
};
}
scrollUp(lines: number): void {
const buffer = this.xterm.buffer.active;
const maxScroll = Math.max(0, buffer.length - this.xterm.rows);
this.scrollOffset = Math.min(this.scrollOffset + lines, maxScroll);
this.followBottom = false; // User scrolled up, stop auto-following
}
scrollDown(lines: number): void {
this.scrollOffset = Math.max(0, this.scrollOffset - lines);
// If scrolled to bottom, resume auto-following
if (this.scrollOffset === 0) {
this.followBottom = true;
}
}
scrollToBottom(): void {
this.scrollOffset = 0;
this.followBottom = true;
}
isScrolledUp(): boolean {
return this.scrollOffset > 0;
}
kill(signal: string = "SIGTERM"): void {
if (this._exited) return;
const pid = this.ptyProcess.pid;
// Try to kill the entire process tree (prevents orphan child processes)
if (process.platform !== "win32" && pid) {
try {
// Kill process group (negative PID)
process.kill(-pid, signal as NodeJS.Signals);
return;
} catch {
// Fall through to direct kill
}
}
// Direct kill as fallback
try {
this.ptyProcess.kill(signal);
} catch {
// Process may already be dead
}
}
dispose(): void {
this.kill();
this.xterm.dispose();
}
}
const fs = require("node:fs");
const path = require("node:path");
function ensureExecutable(filePath) {
try {
const stats = fs.statSync(filePath);
const mode = stats.mode | 0o111;
if ((stats.mode & 0o111) !== 0o111) {
fs.chmodSync(filePath, mode);
process.stdout.write(`chmod +x ${filePath}\n`);
}
} catch (error) {
process.stdout.write(`skip ${filePath}: ${String(error)}\n`);
}
}
function main() {
let pkgPath;
try {
pkgPath = require.resolve("node-pty/package.json", { paths: [process.cwd()] });
} catch (error) {
process.stdout.write(`node-pty not found: ${String(error)}\n`);
return;
}
const base = path.dirname(pkgPath);
const targets = [
path.join(base, "prebuilds", "darwin-arm64", "spawn-helper"),
path.join(base, "prebuilds", "darwin-x64", "spawn-helper"),
];
for (const target of targets) {
ensureExecutable(target);
}
}
main();
#!/usr/bin/env node
import { existsSync, mkdirSync, cpSync, symlinkSync, unlinkSync, readFileSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(__dirname, "..");
const EXTENSION_DIR = join(homedir(), ".pi", "agent", "extensions", "interactive-shell");
const SKILL_DIR = join(homedir(), ".pi", "agent", "skills", "interactive-shell");
function log(msg) {
console.log(`[pi-interactive-shell] ${msg}`);
}
function main() {
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8"));
log(`Installing version ${pkg.version}...`);
// Create extension directory
log(`Creating ${EXTENSION_DIR}`);
mkdirSync(EXTENSION_DIR, { recursive: true });
// Read files list from package.json (single source of truth)
// Include package.json itself (npm auto-includes it but it's not in the files array)
const files = ["package.json", ...(pkg.files || [])];
// Copy files and directories
for (const rawEntry of files) {
// Normalize: remove trailing slashes for consistent handling
const entry = rawEntry.replace(/\/+$/, "");
const src = join(packageRoot, entry);
const dest = join(EXTENSION_DIR, entry);
if (!existsSync(src)) {
continue;
}
try {
const stat = statSync(src);
if (stat.isDirectory()) {
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
log(`Copied ${entry}/`);
} else {
cpSync(src, dest);
log(`Copied ${entry}`);
}
} catch (error) {
log(`Warning: Could not copy ${entry}: ${error.message}`);
}
}
// Run npm install in extension directory
log("Running npm install...");
try {
execSync("npm install", { cwd: EXTENSION_DIR, stdio: "inherit" });
} catch (error) {
log(`Warning: npm install failed: ${error.message}`);
log("You may need to run 'npm install' manually in the extension directory.");
}
// Create skill symlink
log(`Creating skill symlink at ${SKILL_DIR}`);
mkdirSync(SKILL_DIR, { recursive: true });
const skillLink = join(SKILL_DIR, "SKILL.md");
const skillTarget = join(EXTENSION_DIR, "SKILL.md");
try {
// Remove existing entry if present (handles regular files, symlinks, and broken symlinks)
// Note: existsSync returns false for broken symlinks, so we unconditionally try unlink
try {
unlinkSync(skillLink);
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
symlinkSync(skillTarget, skillLink);
log("Skill symlink created");
} catch (error) {
log(`Warning: Could not create skill symlink: ${error.message}`);
log(`You can create it manually: ln -sf ${skillTarget} ${skillLink}`);
}
log("");
log("Installation complete!");
log("");
log("Restart pi to load the extension.");
log("");
log("Usage:");
log(' interactive_shell({ command: \'pi "Fix all bugs"\', mode: "hands-free" })');
log("");
}
main();
/**
* Shared types and interfaces for the interactive shell extension.
*/
export interface InteractiveShellResult {
exitCode: number | null;
signal?: number;
backgrounded: boolean;
backgroundId?: string;
cancelled: boolean;
timedOut?: boolean;
sessionId?: string;
userTookOver?: boolean;
/** When user triggers "Transfer" action, this contains the captured output */
transferred?: {
lines: string[];
totalLines: number;
truncated: boolean;
};
/** Captured before PTY disposal for dispatch mode completion notifications */
completionOutput?: {
lines: string[];
totalLines: number;
truncated: boolean;
};
handoffPreview?: {
type: "tail";
when: "exit" | "detach" | "kill" | "timeout" | "transfer";
lines: string[];
};
handoff?: {
type: "snapshot";
when: "exit" | "detach" | "kill" | "timeout" | "transfer";
transcriptPath: string;
linesWritten: number;
};
}
export interface HandsFreeUpdate {
status: "running" | "user-takeover" | "exited" | "killed";
sessionId: string;
runtime: number;
tail: string[];
tailTruncated: boolean;
userTookOver?: boolean;
// Budget tracking
totalCharsSent?: number;
budgetExhausted?: boolean;
}
export interface InteractiveShellOptions {
command: string;
cwd?: string;
name?: string;
reason?: string;
handoffPreviewEnabled?: boolean;
handoffPreviewLines?: number;
handoffPreviewMaxChars?: number;
handoffSnapshotEnabled?: boolean;
handoffSnapshotLines?: number;
handoffSnapshotMaxChars?: number;
// Hands-free / dispatch mode
mode?: "interactive" | "hands-free" | "dispatch";
sessionId?: string; // Pre-generated sessionId for non-blocking modes
handsFreeUpdateMode?: "on-quiet" | "interval";
handsFreeUpdateInterval?: number;
handsFreeQuietThreshold?: number;
handsFreeUpdateMaxChars?: number;
handsFreeMaxTotalChars?: number;
onHandsFreeUpdate?: (update: HandsFreeUpdate) => void;
// Auto-exit when output stops (for agents that don't exit on their own)
autoExitOnQuiet?: boolean;
// Auto-kill timeout
timeout?: number;
// Existing PTY session (for attach flow -- skip creating a new PTY)
existingSession?: import("./pty-session.js").PtyTerminalSession;
}
export type DialogChoice = "kill" | "background" | "transfer" | "cancel";
export type OverlayState = "running" | "exited" | "detach-dialog" | "hands-free";
// UI constants
export const FOOTER_LINES = 6;
export const HEADER_LINES = 4;
export const CHROME_LINES = HEADER_LINES + FOOTER_LINES + 2;
/** Format milliseconds to human-readable duration */
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
/** Format milliseconds with ms precision for shorter durations */
export function formatDurationMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}