Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View File

@@ -0,0 +1,531 @@
# Changelog
All notable changes to the `pi-interactive-shell` extension will be documented in this file.
## [Unreleased]
## [0.13.0] - 2026-04-23
### Changed
- Bundled runtime skills now live under `skills/`, with only the canonical `pi-interactive-shell` skill auto-registered. The Codex workflow skills remain packaged under `examples/skills/` as opt-in copies alongside the example prompt templates.
- Codex docs now include `gpt-image-2` guidance across the optional `codex-cli` example skill plus the shared README and interactive-shell skill, covering natural-language prompting, `$imagegen`, and `-i` reference-image workflows.
- Replaced the legacy `@sinclair/typebox` runtime dependency with `typebox`.
- Upgraded `zigpty` from `^0.0.6` to `^0.1.6` to pick up newer PTY prebuilds, including the Linux x64 path affected by the reported SIGILL crash.
- Added first-class Cursor spawn support (`spawn.agent: "cursor"` and `/spawn cursor`) mapped to the Cursor CLI `agent` executable by default, with default args set to `--model composer-2-fast`, fresh/worktree support, and Pi-only fork preserved.
- Added an optional `examples/skills/cursor-cli` reference skill and updated spawn docs/tool help/tests so Cursor is treated as a peer to Pi/Codex/Claude in structured spawn flows.
- Updated the optional `codex-cli` example skill to prefer `gpt-5.5` for Codex CLI work.
### Fixed
- Migrated the interactive-shell tool schema from `@sinclair/typebox` to `typebox` 1.x so packaged installs follow Pi's current extension runtime contract.
### Removed
- Removed the legacy npm bin installer (`scripts/install.js`) and its package metadata. `pi install npm:pi-interactive-shell` is now the only supported installation path.
## [0.12.0] - 2026-04-12
### Added
- Inline threshold trigger support for regex monitors via `threshold: { captureGroup, op, value }` with `lt`, `lte`, `gt`, and `gte` operators.
- First-class `file-watch` monitor strategy with `monitor.fileWatch` config (`path`, `recursive`, `events`) and compact event lines (`EVENT path`).
- Monitor lifecycle notifications (`interactive-shell-monitor-lifecycle`) with explicit terminal reasons: `stream-ended`, `script-failed`, `stopped`, and `timed-out`.
- New monitor query fields: `monitorStatus`, `monitorSinceEventId`, and `monitorTriggerId`.
### Changed
- Monitor mode now allows generated internal commands for `file-watch`, so users can start file watchers without providing a shell `command`.
- Monitor validation is now stricter for strategy-specific config (`fileWatch` and `poll` usage) and threshold trigger requirements.
- Monitor coordinator now tracks per-session monitor state (status, strategy, trigger ids, event count, last event metadata, terminal reason).
- Background session UI/listing now renders monitor sessions with monitor-specific context (strategy/event count) instead of plain generic running/exited labels.
## [0.11.1] - 2026-04-12
### Changed
- Monitor event callback now guards against emitting after the monitor is disposed, preventing stale queued notifications from a dismissed session.
- Poll-diff strategy now wraps the command in a recurring loop and diffs per-interval samples instead of accumulating full PTY output.
- Monitor event history cleanup retries until referenced monitor/session/active entries are gone, preventing history leaks from one-shot timers firing too early.
### Fixed
- Fixed `await` on already-resolved detector command promise (removed unnecessary `await` on non-async return).
## [0.11.0] - 2026-04-11
### Added
- New `mode: "monitor"` for `interactive_shell` to run headless background commands and wake the agent only when output lines match `monitorFilter`.
- New `monitorFilter` tool parameter supporting plain-text substring matching and `/regex/flags` matching.
- Monitor event notifications now wake the agent with `triggerTurn` and include `sessionId`, matched text, and the matched line.
- Regression coverage for monitor mode startup validation and ANSI-stripped line matching.
- Regression coverage for suppressing repeated wakeups when the exact same cleaned matching line is emitted more than once in a single monitor session.
### Changed
- README, tool help, and the bundled `examples/skills/interactive-shell/SKILL.md` now document monitor mode usage, event-driven behavior, and monitor session lifecycle with existing background-session APIs.
- Monitor mode now suppresses repeated wakeups for the exact same cleaned matching line within a single monitor session, while still waking on distinct matching lines.
### Fixed
- Slash-prefixed plain-text filters like `/tmp/log` are now treated as literal text instead of being misparsed as regex literals.
- Invalid monitor regex errors now preserve the underlying parser message for easier debugging.
## [0.10.8] - 2026-04-09
### Added
- `submit` tool parameter for `interactive_shell` session input so the agent can type text and press Enter in one call, avoiding the common failure mode where commands are left sitting in editor-based TUIs like pi.
- Regression tests covering `submit: true` for plain text input and bracketed paste input.
### Changed
- PTY backend switched from `node-pty` to `zigpty` in `pty-session.ts`, keeping the existing `PtyTerminalSession` behavior and higher-level `interactive_shell` API unchanged.
- Input docs now explicitly state that raw `input` only types text and does not submit it.
- README, `SKILL.md`, and tool help now prefer `submit: true` or `inputKeys: ["enter"]` over relying on `\n` for command submission.
- The registered `interactive_shell` prompt snippet now nudges agents to use `submit=true` when sending slash commands or prompts to an existing session.
- Structured input now emits bracketed paste content before trailing key presses, so combinations like paste-plus-Enter submit in the expected order.
### Removed
- Removed the `node-pty` macOS spawn-helper permission workaround from runtime and install scripts (`spawn-helper.ts`, `scripts/fix-spawn-helper.cjs`, and the `postinstall` hook).
## [0.10.7] - 2026-04-04
### Added
- Prompt-bearing monitored spawn for `/spawn`, so users can launch delegated hands-free or dispatch sessions like `/spawn claude "review the diffs" --dispatch` without dropping down to raw tool calls.
- Native startup prompt support on structured `interactive_shell` spawn params via `spawn.prompt` for Pi, Codex, and Claude.
### Changed
- `/spawn` now parses quoted positional prompt text plus `--hands-free` or `--dispatch`, while plain `/spawn` remains an interactive overlay launch.
- README and tool docs now spell out that `/spawn` and structured `spawn` share the same resolver semantics, and that `Ctrl+G` only applies after taking over a genuinely monitored session.
- README now includes a dedicated prompt-bearing `/spawn` subsection so the interactive vs monitored split is easier to find.
## [0.10.6] - 2026-04-04
### Added
- Multi-agent spawn support for `pi`, Codex CLI, and Claude Code. `/spawn` can now launch the configured default agent, accept explicit agent overrides like `/spawn codex`, and support `--worktree` for spawning into a separate git worktree.
- First-class `spawn` params on the `interactive_shell` tool so the agent can use the same spawn abstraction directly instead of building raw command strings by hand.
- Regression coverage for dispatch background recovery when a backgrounded session cannot be looked up after overlay teardown.
### Changed
- Spawn config now lives under a nested `spawn` object with `defaultAgent`, `shortcut`, `commands`, `defaultArgs`, `worktree`, and `worktreeBaseDir`.
- The spawn shortcut now launches the configured default spawn agent instead of always launching Pi.
- Pi-only fork validation is shared between `/spawn` and the `interactive_shell` tool, so `fork` now fails fast with a clear error for Codex and Claude.
- README and tool schema examples now document structured spawn usage, multi-agent `/spawn` commands, and worktree settings.
### Fixed
- Pi fork now validates the persisted source session before creating a worktree, so failed fork attempts no longer leave stray worktrees behind.
- Dispatch background recovery now releases the source session and disposes stale monitor state if the expected background session entry is missing after handoff.
- Generated worktree paths now include enough uniqueness to avoid collisions during rapid repeated spawns.
## [0.10.5] - 2026-04-04
### Added
- `spawnShortcut` config setting for the fresh-session overlay shortcut. Defaults to `alt+shift+p` and is pinned at startup like `focusShortcut`, so changes apply on reload or restart.
### Changed
- Fresh-session shortcut registration now reads from config at startup instead of a hardcoded constant, so custom `spawnShortcut` values are applied consistently.
- Docs and config parity tests now cover `spawnShortcut` defaults and README alignment.
### Fixed
- Overlay row/header rendering now clamps metadata and row content at narrow widths, preventing visual overflow when focus badge + PID metadata are wider than the available space.
## [0.10.4] - 2026-04-04
### Fixed
- Focus shortcut handling now uses a terminal input listener while the overlay is open, so the configured `focusShortcut` toggles focus/unfocus reliably even when editor-level shortcuts would not fire. The default shortcut is now `alt+shift+f` instead of `alt+\`` for better terminal compatibility on macOS and to avoid Pi keybinding conflicts.
- Overlay shortcut interception now ignores raw key release and key repeat events, which prevents the focus toggle from firing twice on Kitty-enabled terminals and cancelling itself out.
- Overlay focus state is now more obvious visually: the shell shows a persistent `SHELL FOCUSED` or `EDITOR FOCUSED` badge and switches to a stronger border treatment when focused.
- `alt+/` side chat is blocked while `pi-interactive-shell` is open and shows a warning instead of opening on top of the shell overlay.
## [0.10.3] - 2026-04-04
### Changed
- Added a `promptSnippet` for `interactive_shell` so Pi 0.59+ includes the tool in the default prompt tool list and keeps delegation guidance explicit (`dispatch` preferred by default).
## [0.10.2] - 2026-04-04
### Added
- **Focus switching** — configurable `focusShortcut` (default `alt+shift+f`) toggles focus between overlay and main chat. Same shortcut inside the overlay unfocuses back. Overlay uses `nonCapturing` mode with handle-based focus control.
- **`/spawn` command** — launch pi in an overlay with `/spawn` (fresh session) or `/spawn fork` (fork current session with platform-aware shell quoting).
- **`Alt+Shift+P` shortcut** — quick-launch a fresh pi session overlay.
- **Return-to-agent control** — after taking over a hands-free session, press `Ctrl+G` or select "Return control to agent" from the `Ctrl+Q` menu to resume agent monitoring. Re-registers session in streaming mode and restarts hands-free update timers.
- **`agent-resumed` status** — new `HandsFreeUpdate.status` value emitted when the user returns control to the agent. Handled in both streaming and non-blocking notification paths.
- **Transfer output from commands** — `Ctrl+T` transfer results from `/spawn` and `/attach` now flow back into the agent conversation via shared `emitTransferredOutput()` helper, matching the tool-call behavior.
- **Per-session completion suppression** — `agentHandledCompletion` moved from a single flag to a `Set<string>` on the coordinator, so concurrent sessions can't interfere with each other's notification paths.
- **Stale monitor cleanup** — `disposeStaleMonitor()` helper cleans up orphan headless monitors and their active-session registrations when a background session has already been removed.
- **3 new test files** (10 tests): `spawn-command.test.ts` (fresh, fork, quoting, persist guard, transfer forwarding), `command-session-selection.test.ts` (IDs containing delimiters), `kill-session-suppression.test.ts` (conditional mark on incomplete/complete sessions).
### Changed
- `/attach` and `/dismiss` selection uses structured `{ id, label }` option mapping with `.find()` instead of parsing rendered label strings by delimiter. Session IDs containing ` - ` or ` (` no longer break selection.
- Kill suppression is conditional on completion state — `markAgentHandledCompletion` only set when `session.getResult()` is not yet available, preventing leaked suppression tokens for already-completed sessions.
- `spawn-helper.ts` uses inline ENOENT narrowing instead of single-use `getErrnoCode` helper.
- Dynamic dialog footer height (`dialogOptions.length + 2`) in the overlay accommodates the variable return-to-agent option. Reattach overlay keeps the static `FOOTER_LINES_DIALOG` constant (always 4 options).
- Flattened nested if/else in footer rendering for both overlay components.
- `createOverlayUiOptions()` deduplicates overlay UI configuration across all call sites.
- `runtime-coordinator.ts` manages overlay focus via `OverlayHandle` (focus, unfocus, set, clear).
- Config parse errors now pass the full error object to `console.error` instead of `String(error)`.
- Shutdown kill failure preserves slug reservation to prevent ID collision with potentially still-running sessions.
- Removed legacy `session_switch` lifecycle setup and rely on immutable-session `session_start` reinitialization for background widget setup.
### Fixed
- Duplicate completion notifications on monitored attach + transfer (transfer now marks `agentHandledCompletion` before monitor fires).
- Cancelled dispatch sessions reported as "completed" — now correctly reports "was killed".
- Stale headless monitors leaked when the corresponding background session was already cleaned up.
- Zombie active-session registrations left behind on stale monitor disposal.
- PTY event handlers not reset on attach failure recovery, causing stale overlay callbacks on disposed components.
## [0.10.1] - 2026-03-13
### Fixed
- **Skill name mismatch** - SKILL.md declared `name: interactive-shell` but pi expects it to match the parent directory `pi-interactive-shell`. Fixed skill name to match package name.
## [0.10.0] - 2026-03-13
### Added
- **Test harness** - Added vitest with 20 tests covering session queries, key encoding, notification formatting, headless monitor lifecycle, session manager, config/docs parity, and module loading.
- **`gpt-5-4-prompting` skill** - New bundled skill with GPT-5.4 prompting best practices for Codex workflows.
### Changed
- **Architecture refactor** - Extracted shared logic into focused modules for better maintainability:
- `session-query.ts` - Unified output/query logic (rate limiting, incremental, drain, offset modes)
- `notification-utils.ts` - Message formatting for dispatch/hands-free notifications
- `handoff-utils.ts` - Snapshot/preview capture on session exit/transfer
- `runtime-coordinator.ts` - Centralized overlay/monitor/widget state management
- `pty-log.ts` - Raw output trimming and line slicing
- `pty-protocol.ts` - DSR cursor position query handling
- `spawn-helper.ts` - macOS node-pty permission fix
- `background-widget.ts` - TUI widget for background sessions
- README, `SKILL.md`, install output, and the packaged Codex workflow examples now tell the same story about dispatch being the recommended delegated mode, the current 8s quiet threshold / 15s grace-period defaults, and the bundled prompt-skill surface.
- The Codex workflow docs now point at the packaged `gpt-5-4-prompting`, `codex-5-3-prompting`, and `codex-cli` skills instead of describing a runtime fetch of the old 5.2 prompting guide.
- Example prompts and skill docs are aligned around `gpt-5.4` as the default Codex model, with `gpt-5.3-codex` remaining the explicit opt-in fallback.
- Renamed `codex-5.3-prompting``codex-5-3-prompting` example skill (filesystem-friendly path).
### Fixed
- **Map iteration bug** - Fixed `disposeAllMonitors()` modifying Map during iteration, which could cause unpredictable behavior.
- **Array iteration bug** - Fixed PTY listener notifications modifying arrays during iteration if a listener unsubscribed itself.
- **Missing runtime dependency** - Added `@sinclair/typebox` to dependencies (was imported but not declared).
- Documented the packaged prompt/skill onboarding path more clearly so users can either rely on the exported package metadata or copy the bundled examples into their own prompt and skill directories.
## [0.9.0] - 2026-02-23
### Added
- `examples/skills/codex-5.3-prompting/` skill with GPT-5.3-Codex prompting guide -- self-contained best practices for verbosity control, scope discipline, forced upfront reading, plan mode, mid-task steering, context management, and reasoning effort recommendations.
- **`interactive-shell:update` event** — All hands-free update callbacks now emit `pi.events.emit("interactive-shell:update", update)` with the full `HandsFreeUpdate` payload. Extensions can listen for quiet, exit, kill, and user-takeover events regardless of which code path started the session (blocking, non-blocking, or reattach).
- **`triggerTurn` on terminal events** — Non-blocking hands-free sessions now send `pi.sendMessage` with `triggerTurn: true` when the session exits, is killed, or the user takes over. Periodic "running" updates emit only on the event bus (cheap for extensions) without waking the agent.
### Fixed
- **Quiet detection broken for TUI apps** — Ink-based CLIs (Claude Code, etc.) emit periodic ANSI-only PTY data (cursor blink, frame redraws) that reset the quiet timer on every event, preventing quiet detection from ever triggering. Now filters data through `stripVTControlCharacters` and only resets the quiet timer when there's visible content. Fixed in both the overlay (`overlay-component.ts`) and headless dispatch monitor (`headless-monitor.ts`). Also seeds the quiet timer at startup when `autoExitOnQuiet` is enabled, so sessions that never produce visible output still get killed after the grace period.
- **Lifecycle guard decoupled from callback** — The overlay used `onHandsFreeUpdate` presence as a proxy for "blocking tool call" to decide whether to unregister sessions on completion. Wiring the callback in non-blocking paths (for event emission) would cause premature session cleanup. Introduced `streamingMode` flag to separate "has update callback" from "should unregister on completion," so non-blocking sessions stay queryable after the callback fires.
- **`autoExitOnQuiet` broken in interval update mode** — The `onData` handler only reset the quiet timer in `on-quiet` mode, so `autoExitOnQuiet` never fired with `updateMode: "interval"`. Also, the interval timer's safety-net flush unconditionally stopped the quiet timer, preventing `autoExitOnQuiet` from firing if the interval flushed before the quiet threshold. Both fixed: data handler now resets the timer whenever `autoExitOnQuiet` is enabled regardless of update mode, and the interval flush restarts (rather than stops) the quiet timer when `autoExitOnQuiet` is active.
- **RangeError on narrow terminals** — `render()` computed `width - 2` for border strings without a lower bound, causing `String.prototype.repeat()` to throw with negative counts when terminal width < 4. Clamped in both the main overlay and reattach overlay. Fixes #2.
- **Hardcoded `~/.pi/agent` path** — Config loading, snapshot writing, and the install script all hardcoded `~/.pi/agent`, ignoring `PI_CODING_AGENT_DIR`. Now uses `getAgentDir()` from pi's API in all runtime paths and reads the env var in the install script. Fixes #1.
### Changed
- Default `handsFreeQuietThreshold` increased from 5000ms to 8000ms and `autoExitGracePeriod` reduced from 30000ms to 15000ms. Both remain adjustable per-call via `handsFree.quietThreshold` and `handsFree.gracePeriod`, and via config file.
- Dispatch mode is now the recommended default for delegated Codex runs. Updated `README.md`, `SKILL.md`, `tool-schema.ts`, `examples/skills/codex-cli/SKILL.md`, and all three codex prompt templates to prefer `mode: "dispatch"` over hands-free for fire-and-forget delegations.
- Rewrote `codex-5.3-prompting` skill from a descriptive model-behavior guide into a directive prompt-construction reference. Cut behavioral comparison, mid-task steering, and context management prose sections; reframed each prompt block with a one-line "include when X" directive so the agent knows what to inject and when.
- Added "Backwards compatibility hedging" section to `codex-5.3-prompting` skill covering the "cutover" keyword trick -- GPT-5.3-Codex inserts compatibility shims and fallback code even when told not to; using "cutover" + "no backwards compatibility" + "do not preserve legacy code" produces cleaner breaks than vague "don't worry about backwards compatibility" phrasing.
- Example prompts (`codex-implement-plan`, `codex-review-impl`, `codex-review-plan`) updated for GPT-5.3-Codex: load `codex-5.3-prompting` and `codex-cli` skills instead of fetching the 5.2 guide URL at runtime, added scope fencing instructions to counter 5.3's aggressive refactoring, added "don't ask clarifying questions" and "brief updates" constraints, strengthened `codex-review-plan` to force reading codebase files referenced in the plan and constrain edit scope.
## [0.8.2] - 2026-02-10
### Added
- `examples/prompts/` with three Codex CLI prompt templates: `codex-review-plan`, `codex-implement-plan`, `codex-review-impl`. Demonstrates a plan → implement → review workflow using meta-prompt generation and interactive shell overlays.
- `examples/skills/codex-cli/` skill that teaches pi Codex CLI flags, config, sandbox caveats, and interactive_shell usage patterns.
- README section documenting the workflow pipeline, installation, usage examples, and customization.
## [0.8.1] - 2026-02-08
### Fixed
- README: documented `handsFree.gracePeriod` tool parameter and startup grace period behavior in Auto-Exit on Quiet and Dispatch sections.
- README: added missing `handoffPreviewLines` and `handoffPreviewMaxChars` to config settings table.
## [0.8.0] - 2026-02-08
### Added
- `autoExitGracePeriod` config option (default: 30000ms, clamped 5000-120000ms) and `handsFree.gracePeriod` tool parameter override for startup quiet-kill grace control.
### Changed
- Default `overlayHeightPercent` increased from 45 to 60 for improved usable terminal rows on smaller displays.
- Overlay sizing now uses dynamic footer chrome: compact 2-line footer in normal states and full 6-line footer in detach dialog, increasing terminal viewport height during normal operation.
### Fixed
- Dispatch/hands-free `autoExitOnQuiet` no longer kills sessions during startup silence; quiet timer now re-arms during grace period and applies auto-kill only after grace expires.
- README config table missing `handoffPreviewLines` and `handoffPreviewMaxChars` entries despite appearing in the JSON example.
## [0.7.1] - 2026-02-03
### Changed
- Added demo video and `pi.video` field 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 via `triggerTurn` instead of polling. Defaults `autoExitOnQuiet: 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** - `completionOutput` captured before PTY disposal in all `finishWith*` methods for dispatch notifications.
- `completionNotifyLines` and `completionNotifyMaxChars` config options for notification output size.
- **Dismiss background sessions** - `/dismiss [id]` user command and `dismissBackground` tool 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()` and `addExitListener()` allow multiple subscribers alongside the primary `setEventHandlers()`. 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).
- `ActiveSession` interface now includes `background()` method.
- Overlay `onExit` handler 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()` returns `completionOutput` as fallback when session is finished.
- `/attach` command 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.completionOutput` or 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()` and `setUpdateInterval()` no longer kills autoExitOnQuiet detection in dispatch mode (guarded on-quiet branch with `onHandsFreeUpdate` null 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 produced `WorkingWorking•orking•rking•king•ing...`). Switched both `captureTransferOutput()` and `maybeBuildHandoffPreview()` to use `getTailLines()` 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 both `overlay-component.ts` and `reattach-overlay.ts`.
- **Removed dead code** - Cleaned up unused private fields (`timedOut`, `lastDataTime`) and unreachable method (`getSessionId()`) from `InteractiveShellOverlay`.
## [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()` with `triggerTurn: 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.events` when 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) and `transferMaxChars` (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-package` keyword 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 from `package.json`'s `files` array 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()` returns `false` for 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_shell` session or using `/attach` while 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 `input` parameter which previously accepted either a string or an object with `text/keys/hex/paste` fields 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 sequences
- `inputPaste` - 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
```typescript
// 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 `-f` flag) now properly collapse to a single line in the overlay header instead of overflowing and leaking behind the overlay.
- **Reason field overflow** - The `reason` field in the hint line is also sanitized to prevent newline overflow.
- **Session list overflow** - The `/attach` command'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: true` parameter for server-tracked pagination. Agent calls repeatedly and server tracks position automatically. Returns `hasMore` to indicate when more output is available.
- **hasMore in offset mode** - Offset pagination now returns `hasMore` field 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 under `settings.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 `outputOffset` parameter for reading specific ranges of output:
- `outputOffset: 0, outputLines: 50` reads lines 0-49
- `outputOffset: 50, outputLines: 50` reads lines 50-99
- Returns `totalLines` in response for pagination
- **Drain mode for incremental output** - New `drain: true` parameter 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 `outputLines` and `outputMaxChars` parameters 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 resetting `showEscapeHint` to false after setting it to true
- **Negative output limits** - Added clamping to ensure `outputLines` and `outputMaxChars` are 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 `minQueryIntervalSeconds` in 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 `overlayHeightPercent` in 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 `clampInt` like 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 `hasOutput` since 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. Use `interactive_shell({ sessionId })` to query status/output and `interactive_shell({ sessionId, kill: true })` to end the session when done.
- **Session status queries** - Query active session with just `sessionId` to 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 `getOutput` return type to match `OutputResult` interface.
- **Agent output position after buffer trim** - Fixed `agentOutputPosition` becoming 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 `output` field was required but never populated. Updated interface to match actual return type from `getResult()`.
- **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` + `input` parameters.
- Named key support: `up`, `down`, `enter`, `escape`, `ctrl+c`, etc.
- "Foreground subagents" terminology to distinguish from background subagents (the `subagent` tool).
- `sessionId` now available in the first update (before overlay opens) for immediate input injection.
- **Timeout**: Auto-kill process after N milliseconds via `timeout` parameter. 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-cove` instead of `shell-1`, `shell-2`.
- **Process tree killing**: Kill entire process tree on termination, preventing orphan child processes.
- **Session name derivation**: Better display names in `/attach` list showing command summary.
- **Write queue**: Ordered writes to terminal emulator prevent race conditions.
- **Raw output streaming**: `getRawStream()` method for incremental output reading with `sinceLast` option.
- **Exit message in terminal**: Process exit status appended to terminal buffer when process exits.
- **EOL conversion**: Added `convertEol: true` to 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.updateMode` can 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.`, `kpenter` for 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 `stripVTControlCharacters` for cleaner, more robust output processing.
### Fixed
- Double unregistration in hands-free session cleanup (now idempotent via `sessionUnregistered` flag).
- Potential double `done()` call when timeout fires and process exits simultaneously (added `finished` guard).
- ReattachOverlay: untracked setTimeout for initial countdown could fire after dispose (now tracked).
- Input type annotation missing `hex` and `paste` fields.
- 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.updateLines` option (was defined but unused after switch to incremental char-based updates).
## [0.2.0] - 2026-01-17
### Added
- Interactive shell overlay tool `interactive_shell` for 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.

View File

@@ -0,0 +1,586 @@
<p>
<img src="banner.png" alt="pi-interactive-shell" width="1100">
</p>
# Pi Interactive Shell
An extension for [Pi coding agent](https://github.com/badlogic/pi-mono/) that lets Pi autonomously run interactive CLIs in an observable TUI overlay. Pi controls the subprocess while you watch - take over anytime.
https://github.com/user-attachments/assets/76f56ecd-fc12-4d92-a01e-e6ae9ba65ff4
```typescript
interactive_shell({ command: 'vim config.yaml' })
```
Important: the `interactive_shell({...})` snippets in this README are tool calls made by Pi (or extension/prompt authors). End users do not type these directly into chat. As a user, ask Pi to run something (for example: "run this in dispatch mode") or use `/spawn`, `/attach`, and `/dismiss` commands.
## Why
Some tasks need interactive CLIs - editors, REPLs, database shells, long-running processes. Pi can launch them in an overlay where:
- **User watches** - See exactly what's happening in real-time
- **User takes over** - Type anything to gain control
- **Agent monitors** - Query status, send input, decide when done
Works with any CLI: `vim`, `htop`, `psql`, `ssh`, `docker logs -f`, `npm run dev`, `git rebase -i`, etc.
## Install
```bash
pi install npm:pi-interactive-shell
```
The `interactive-shell` skill is automatically symlinked to `~/.pi/agent/skills/interactive-shell/`.
**Requires:** Node.js. PTY support uses `zigpty` prebuilt binaries (no `node-gyp` toolchain required on supported platforms).
## Modes
| Mode | Agent waits? | How output reaches agent | Best for |
|---|---|---|---|
| **Interactive** (default) | Yes — blocks until exit | Tool return value | Editors, REPLs, SSH — when you need the result now |
| **Hands-free** | No | Poll with `sessionId` | Dev servers, builds — when you want to watch progress and send follow-up commands |
| **Dispatch** | No | Notification on completion via `triggerTurn` | Delegating tasks to subagents — fire and forget |
| **Monitor** | No | Notification on structured monitor trigger events | Watchers, logs, tests, and state checks — wake only when something specific happens |
**Interactive** — The overlay opens, user controls the session, agent waits for it to close. Use for editors (`vim`), database shells (`psql`), or any task where the agent needs the final result immediately.
**Hands-free** — The overlay opens but returns immediately. The agent polls periodically with `sessionId` to check status and get new output. Good for long-running builds or dev servers where you want to react mid-flight (send input, check logs, kill when ready).
**Dispatch** — Returns immediately. No polling. The agent gets woken up via `triggerTurn` only when the session completes (natural exit, timeout, quiet detection, or user kill). The notification includes a tail of the output. This is the default for delegating work to subagents. Add `background: true` to skip the overlay entirely.
**Monitor** — Returns immediately. No polling, no completion notification. The agent gets woken up when a configured monitor trigger emits an event. Supports stream triggers, poll-diff checks, first-class file watching, optional cooldowns, persistence controls, detector commands, and event history queries. Runs headless; attach to inspect if needed.
## Quick Start
The examples below show agent-side tool calls. They are not chat commands for end users.
### Structured Spawn
For Pi, Codex, Claude, and Cursor, the agent can use structured spawn params instead of building command strings by hand:
```typescript
// User says: "Spawn pi so I can edit files interactively"
interactive_shell({ spawn: { agent: "pi" }, mode: "interactive" })
// User says: "Delegate this refactor to codex and notify me when it's done"
interactive_shell({ spawn: { agent: "codex" }, mode: "dispatch" })
// User says: "Ask cursor to review the diffs in dispatch mode"
interactive_shell({ spawn: { agent: "cursor", prompt: "Review the diffs" }, mode: "dispatch" })
// User says: "Ask claude to review the diffs in dispatch mode"
interactive_shell({ spawn: { agent: "claude", prompt: "Review the diffs" }, mode: "dispatch" })
// User says: "Start claude in a worktree for hands-free monitoring"
interactive_shell({ spawn: { agent: "claude", worktree: true }, mode: "hands-free" })
// User says: "Fork my current pi session" (Pi-only)
interactive_shell({ spawn: { mode: "fork" }, mode: "interactive" })
```
Structured `spawn` uses the same resolver and config defaults as the user-facing `/spawn` command. Raw `command` is still supported for arbitrary CLIs and custom launch strings.
For Codex image or design work, Codex can invoke `gpt-image-2` directly from the prompt. Natural language is usually enough, and `$imagegen` forces the image-generation tool when you need it. Attach references with `-i` for edits and iterations. See the bundled `codex-cli` skill for concrete examples. For Cursor CLI-specific command references, see the optional `examples/skills/cursor-cli` skill. Cursor structured spawn defaults to `--model composer-2-fast`, which explicitly selects Cursor's Composer 2 Fast model.
### Interactive
```typescript
// User says: "Open package.json in vim"
interactive_shell({ command: 'vim package.json' })
// User says: "Connect to the postgres database"
interactive_shell({ command: 'psql -d mydb' })
// User says: "SSH into the server"
interactive_shell({ command: 'ssh user@server' })
```
The agent's turn is blocked until the overlay closes. User controls the session directly.
### Hands-Free
```typescript
// Start a long-running process
interactive_shell({
command: 'npm run dev',
mode: "hands-free",
reason: "Dev server"
})
// → { sessionId: "calm-reef", status: "running" }
// User says: "Check on the dev server status"
interactive_shell({ sessionId: "calm-reef" })
// → { status: "running", output: "Server ready on :3000", runtime: 45000 }
// Send input when needed
interactive_shell({ sessionId: "calm-reef", input: "/run review", submit: true })
interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] })
// Kill when done
interactive_shell({ sessionId: "calm-reef", kill: true })
// → { status: "killed", output: "..." }
```
The overlay opens for the user to watch. The agent checks in periodically. User can type anything to take over control. After taking over a monitored hands-free or dispatch session, press `Ctrl+G` to return control to the agent.
### Dispatch
```typescript
// User says: "Delegate refactoring the auth module to pi and notify me when done"
interactive_shell({
command: 'pi "Refactor the auth module"',
mode: "dispatch",
reason: "Auth refactor"
})
// → Returns immediately: { sessionId: "calm-reef" }
// → Agent ends turn or does other work.
```
When the session completes, the agent receives a compact notification on a new turn:
```
Session calm-reef completed successfully (5m 23s). 847 lines of output.
Step 9 of 10
Step 10 of 10
All tasks completed.
Attach to review full output: interactive_shell({ attach: "calm-reef" })
```
The notification includes a brief tail (last 5 lines) and a reattach instruction. The PTY is preserved for 5 minutes so the agent can attach to review full scrollback.
Dispatch defaults `autoExitOnQuiet: true` — the session gets a 15s startup grace period, then is killed after output goes silent (8s by default), which signals completion for task-oriented subagents. Tune the grace period with `handsFree: { gracePeriod: 60000 }` or opt out entirely with `handsFree: { autoExitOnQuiet: false }`.
The overlay still shows for the user, who can Ctrl+T to transfer output, Ctrl+B to background, take over by typing, or Ctrl+Q for more options. `Ctrl+G` only becomes meaningful after the user has taken over a monitored hands-free or dispatch session.
### Background Dispatch (Headless)
```typescript
// No overlay — runs completely invisibly
interactive_shell({
command: 'pi "Fix all lint errors"',
mode: "dispatch",
background: true
})
// → { sessionId: "calm-reef" }
// → User can /attach calm-reef to peek
// → Agent notified on completion, same as regular dispatch
```
Multiple headless dispatches can run concurrently alongside a single interactive overlay. This is how you parallelize subagent work — fire off three background dispatches and process results as each completion notification arrives.
### Monitor (Event-Driven)
These examples are **agent tool calls**. End users should ask in natural language (for example: "watch my tests and alert me on failures"), and Pi should invoke `interactive_shell` with the monitor config.
Wake the agent when monitor triggers emit events — no polling and no waiting for process completion.
```typescript
// User says: "Watch my tests and alert me on failures or errors"
interactive_shell({
command: 'npm test --watch',
mode: "monitor",
monitor: {
strategy: "stream",
triggers: [
{ id: "failed", literal: "FAIL" },
{ id: "error", regex: "/error|exception/i" }
],
throttle: { dedupeExactLine: true },
persistence: { stopAfterFirstEvent: false }
}
})
// User says: "Monitor the health endpoint and tell me when it changes"
interactive_shell({
command: 'curl -sf http://localhost:3000/health',
mode: "monitor",
monitor: {
strategy: "poll-diff",
triggers: [{ id: "changed", regex: "/./" }],
poll: { intervalMs: 5000 }
}
})
// User says: "Alert me when NVDA drops below $120"
interactive_shell({
command: 'curl -s https://api.example.com/quote/NVDA',
mode: "monitor",
monitor: {
strategy: "stream",
triggers: [
{
id: "nvda-below-120",
regex: "/NVDA:\\s*\\$?(\\d+(?:\\.\\d+)?)/",
threshold: { captureGroup: 1, op: "lt", value: 120 }
}
]
}
})
// User says: "Watch the uploads folder for new PDF files and notify me"
interactive_shell({
mode: "monitor",
monitor: {
strategy: "file-watch",
fileWatch: { path: "./uploads", recursive: true, events: ["rename", "change"] },
triggers: [{ id: "pdf", regex: "/\\.pdf$/i" }]
}
})
```
Monitor mode emits structured payloads (`sessionId`, `eventId`, `timestamp`, `strategy`, `triggerId`, `matchedText`, `lineOrDiff`, `stream`) and now also emits lifecycle notifications when a monitor stops (stream ended, script failed, stopped, or timed out). `monitorFilter` was removed in favor of the structured `monitor` object.
```typescript
interactive_shell({ monitorStatus: true, monitorSessionId: "calm-reef" })
interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef" })
interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef", monitorSinceEventId: 42 })
interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef", monitorTriggerId: "error" })
interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef", monitorEventLimit: 50, monitorEventOffset: 20 })
```
Monitor sessions run headless and can be managed like other background sessions (`listBackground`, `/attach`, `dismissBackground`).
### Timeout
Capture output from TUI apps that don't exit cleanly:
```typescript
interactive_shell({
command: "htop",
mode: "hands-free",
timeout: 3000 // Kill after 3s, return captured output
})
```
## Features
### Auto-Exit on Quiet
For fire-and-forget single-task delegations, enable auto-exit to kill the session after 8s of output silence:
```typescript
interactive_shell({
command: 'pi "Fix the bug in auth.ts"',
mode: "hands-free",
handsFree: { autoExitOnQuiet: true }
})
```
A 15s startup grace period prevents the session from being killed before the subprocess has time to produce output. Customize it per-call with `gracePeriod`:
```typescript
interactive_shell({
command: 'pi "Run the full test suite"',
mode: "hands-free",
handsFree: { autoExitOnQuiet: true, gracePeriod: 60000 }
})
```
The default grace period is also configurable globally via `autoExitGracePeriod` in the config file.
For multi-turn sessions where you need back-and-forth interaction, leave it disabled (default) and use `kill: true` when done.
### Send Input
```typescript
// Text only (types text but does not submit)
interactive_shell({ sessionId: "calm-reef", input: "SELECT * FROM users;" })
// Type text and press Enter
interactive_shell({ sessionId: "calm-reef", input: "SELECT * FROM users;", submit: true })
// Named keys
interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] })
interactive_shell({ sessionId: "calm-reef", inputKeys: ["down", "down", "enter"] })
// Bracketed paste (multiline without execution)
interactive_shell({ sessionId: "calm-reef", inputPaste: "line1\nline2\nline3" })
// Hex bytes (raw escape sequences)
interactive_shell({ sessionId: "calm-reef", inputHex: ["0x1b", "0x5b", "0x41"] })
// Combine text with keys
interactive_shell({ sessionId: "calm-reef", input: "y", inputKeys: ["enter"] })
```
For editor-based TUIs like pi, raw `input` only types text. It does not submit the prompt. Prefer `submit: true` or `inputKeys: ["enter"]` instead of relying on `\n`.
### Configurable Output
```typescript
// Default: 20 lines, 5KB
interactive_shell({ sessionId: "calm-reef" })
// More lines (max: 200)
interactive_shell({ sessionId: "calm-reef", outputLines: 100 })
// Incremental pagination (server tracks position)
interactive_shell({ sessionId: "calm-reef", outputLines: 50, incremental: true })
// Drain mode (raw stream since last query)
interactive_shell({ sessionId: "calm-reef", drain: true })
```
### Transfer Output to Agent
When a subagent finishes work, press **Ctrl+T** to capture its output and send it directly to the main agent:
```
[Subagent finishes work]
[Press Ctrl+T]
[Overlay closes, main agent receives full output]
```
The main agent then has the subagent's response in context and can continue working with that information.
**Configuration:**
- `transferLines`: Max lines to capture (default: 200)
- `transferMaxChars`: Max characters (default: 20KB)
### Background Sessions
Sessions can be backgrounded by the user (Ctrl+B, or Ctrl+Q → "Run in background") or by the agent:
```typescript
// Agent backgrounds an active session
interactive_shell({ sessionId: "calm-reef", background: true })
// → Overlay closes, process keeps running
// List background sessions
interactive_shell({ listBackground: true })
// Reattach with a specific mode
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
interactive_shell({ dismissBackground: true }) // all sessions
interactive_shell({ dismissBackground: "calm-reef" }) // specific session
```
Monitor sessions work the same way — they're headless background sessions that wake you on monitor events instead of completion.
User can also `/spawn` to launch the configured default spawn agent, `/spawn codex`, `/spawn cursor`, `/spawn claude`, `/spawn pi`, `/spawn fork`, or `/spawn pi fork`. Add `--worktree` to spawn in a separate git worktree, for example `/spawn cursor --worktree`, `/spawn codex --worktree`, or `/spawn pi fork --worktree`. Plain `/spawn cursor` stays a normal interactive overlay. `fork` is Pi-only. Worktrees are left in place and the overlay will tell you where they were created. `/attach` or `/attach <id>` reattaches, and `/dismiss` or `/dismiss <id>` cleans up from the chat. The keyboard spawn shortcut is separate from `/spawn` and uses `spawn.shortcut`.
### Prompt-Bearing `/spawn`
Quoted prompt text plus `--hands-free` or `--dispatch` turns `/spawn` into a monitored delegated run instead of a plain interactive overlay. This shares the same resolver and defaults as structured `interactive_shell({ spawn: ... })`. Plain `/spawn` stays interactive. `Ctrl+G` only applies after you take over one of these monitored sessions.
```bash
/spawn cursor "review the diffs" --dispatch
/spawn claude "review the diffs" --dispatch
/spawn codex "fix the failing tests" --hands-free
/spawn pi fork "continue from here" --dispatch
```
## Keys
| Key | Action |
|-----|--------|
| Ctrl+T | **Transfer & close** - capture output and send to main agent |
| Ctrl+B | Background session (dismiss overlay, keep running) |
| Ctrl+Q | Session menu (transfer/background/kill/cancel) |
| Shift+Up/Down | Scroll history |
| Alt+Shift+F (default) | Toggle focus between overlay and main chat (`focusShortcut`) |
| Ctrl+G | Return to agent monitoring (only after taking over a monitored hands-free or dispatch session) |
| Alt+Shift+P (default) | Launch the configured default spawn agent (`spawn.shortcut`) |
| Any key (hands-free) | Take over control |
## Config
Configuration files (project overrides global):
- **Global:** `~/.pi/agent/interactive-shell.json`
- **Project:** `.pi/interactive-shell.json`
Shortcut settings are pinned at startup. If you change `focusShortcut` or `spawn.shortcut`, reload or restart Pi to apply them.
```json
{
"overlayWidthPercent": 95,
"overlayHeightPercent": 60,
"focusShortcut": "alt+shift+f",
"spawn": {
"defaultAgent": "pi",
"shortcut": "alt+shift+p",
"commands": {
"pi": "pi",
"codex": "codex",
"claude": "claude",
"cursor": "agent"
},
"defaultArgs": {
"pi": [],
"codex": [],
"claude": [],
"cursor": ["--model", "composer-2-fast"]
},
"worktree": false,
"worktreeBaseDir": "../repo-worktrees"
},
"scrollbackLines": 5000,
"exitAutoCloseDelay": 10,
"minQueryIntervalSeconds": 60,
"transferLines": 200,
"transferMaxChars": 20000,
"completionNotifyLines": 50,
"completionNotifyMaxChars": 5000,
"handsFreeUpdateMode": "on-quiet",
"handsFreeUpdateInterval": 60000,
"handsFreeQuietThreshold": 8000,
"autoExitGracePeriod": 15000,
"handsFreeUpdateMaxChars": 1500,
"handsFreeMaxTotalChars": 100000,
"handoffPreviewEnabled": true,
"handoffPreviewLines": 30,
"handoffPreviewMaxChars": 2000,
"handoffSnapshotEnabled": false,
"ansiReemit": true
}
```
| Setting | Default | Description |
|---------|---------|-------------|
| `overlayWidthPercent` | 95 | Overlay width (10-100%) |
| `overlayHeightPercent` | 60 | Overlay height (20-90%) |
| `focusShortcut` | "alt+shift+f" | Toggle focus between overlay and main chat |
| `spawn.defaultAgent` | "pi" | Configured default spawn agent for `/spawn`, the spawn shortcut, and agent-side structured spawn |
| `spawn.shortcut` | "alt+shift+p" | Keyboard shortcut that launches the configured default spawn agent |
| `spawn.commands.<agent>` | `pi` / `codex` / `claude` / `agent` (cursor) | Executable or path override per spawn agent |
| `spawn.defaultArgs.<agent>` | `[]` (Cursor defaults to `--model composer-2-fast`) | Extra default CLI args per spawn agent |
| `spawn.worktree` | `false` | Launch spawns in a separate git worktree by default |
| `spawn.worktreeBaseDir` | unset | Optional base directory for generated worktrees |
| `scrollbackLines` | 5000 | Terminal scrollback buffer |
| `exitAutoCloseDelay` | 10 | Seconds before auto-close after exit |
| `minQueryIntervalSeconds` | 60 | Rate limit between agent queries |
| `transferLines` | 200 | Lines to capture on Ctrl+T transfer (10-1000) |
| `transferMaxChars` | 20000 | Max chars for transfer (1KB-100KB) |
| `completionNotifyLines` | 50 | Lines in dispatch completion notification (10-500) |
| `completionNotifyMaxChars` | 5000 | Max chars in completion notification (1KB-50KB) |
| `handsFreeUpdateMode` | "on-quiet" | "on-quiet" or "interval" |
| `handsFreeQuietThreshold` | 8000 | Silence duration before update (ms) |
| `autoExitGracePeriod` | 15000 | Startup grace before `autoExitOnQuiet` kill (ms) |
| `handsFreeUpdateInterval` | 60000 | Max interval between updates (ms) |
| `handsFreeUpdateMaxChars` | 1500 | Max chars per update |
| `handsFreeMaxTotalChars` | 100000 | Total char budget for updates |
| `handoffPreviewEnabled` | true | Include tail in tool result |
| `handoffPreviewLines` | 30 | Lines in tail preview (0-500) |
| `handoffPreviewMaxChars` | 2000 | Max chars in tail preview (0-50KB) |
| `handoffSnapshotEnabled` | false | Write transcript on detach/exit |
| `ansiReemit` | true | Preserve ANSI colors in output |
## How It Works
```
interactive_shell → zigpty → subprocess
xterm-headless (terminal emulation)
TUI overlay (pi rendering)
```
Full PTY. The subprocess thinks it's in a real terminal.
## Example Workflow: Plan, Implement, Review
The `examples/prompts/` directory includes three opt-in prompt templates that chain together into a complete development workflow using Codex CLI. Each template loads the example `gpt-5-4-prompting` skill by default, falls back to `codex-5-3-prompting` when the user explicitly asks for Codex 5.3, and launches Codex in an interactive overlay.
### The Pipeline
```
Write a plan
/codex-review-plan path/to/plan.md ← Codex verifies every assumption against the codebase
/codex-implement-plan path/to/plan.md ← Codex implements the reviewed plan faithfully
/codex-review-impl path/to/plan.md ← Codex reviews the diff against the plan, fixes issues
```
### Installing the Templates
Install the package first for the extension and core `pi-interactive-shell` skill:
```bash
pi install npm:pi-interactive-shell
```
The Codex workflow prompts and supporting skills are opt-in examples. Copy them into your agent config if you want to use them:
```bash
# Prompt templates (slash commands)
cp ~/.pi/agent/extensions/pi-interactive-shell/examples/prompts/*.md ~/.pi/agent/prompts/
# Optional skills used by the templates
cp -r ~/.pi/agent/extensions/pi-interactive-shell/examples/skills/codex-cli ~/.pi/agent/skills/
cp -r ~/.pi/agent/extensions/pi-interactive-shell/examples/skills/gpt-5-4-prompting ~/.pi/agent/skills/
cp -r ~/.pi/agent/extensions/pi-interactive-shell/examples/skills/codex-5-3-prompting ~/.pi/agent/skills/
# Optional CLI reference skill
cp -r ~/.pi/agent/extensions/pi-interactive-shell/examples/skills/cursor-cli ~/.pi/agent/skills/
```
### Usage
Say you have a plan at `docs/auth-redesign-plan.md`:
**Step 1: Review the plan** — Codex reads your plan, then verifies every file path, API shape, data flow, and integration point against the actual codebase. Fixes issues directly in the plan file.
```
/codex-review-plan docs/auth-redesign-plan.md
/codex-review-plan docs/auth-redesign-plan.md pay attention to the migration steps
```
**Step 2: Implement the plan** — Codex reads all relevant code first, then implements bottom-up: shared utilities first, then dependent modules, then integration code. No stubs, no TODOs.
```
/codex-implement-plan docs/auth-redesign-plan.md
/codex-implement-plan docs/auth-redesign-plan.md skip test files for now
```
**Step 3: Review the implementation** — Codex diffs the changes, reads every changed file in full (plus imports and dependents), traces code paths across file boundaries, and fixes every issue it finds. Pass the plan to verify completeness, or omit it to just review the diff.
```
/codex-review-impl docs/auth-redesign-plan.md # review diff against plan
/codex-review-impl docs/auth-redesign-plan.md check cleanup ordering
/codex-review-impl # just review the diff, no plan
/codex-review-impl focus on error handling and race conditions
```
### How They Work
These templates demonstrate a "meta-prompt generation" pattern:
1. **Pi gathers context** — reads the plan, runs git diff, and loads the copied local `gpt-5-4-prompting` or `codex-5-3-prompting` skill
2. **Pi generates a calibrated prompt** — tailored to the specific plan/diff, following the selected skill's best practices
3. **Pi launches Codex in the overlay** — defaulting to `-m gpt-5.4 -a never` and switching to `-m gpt-5.3-codex -a never` only when the user explicitly asks for Codex 5.3
The user watches Codex work in the overlay and can take over anytime (type to intervene, Ctrl+T to transfer output back to pi, Ctrl+Q for options).
### Customizing
These are starting points. Fork them and adjust:
- **Model/flags** — swap `gpt-5.3-codex` for another model, change reasoning effort
- **Review criteria** — add project-specific checks (security policies, style rules)
- **Implementation rules** — change the 500-line file limit, add framework-specific patterns
- **Other agents** — adapt the pattern for Claude (`claude "prompt"`), Gemini (`gemini -i "prompt"`), or any CLI
See the [pi prompt templates docs](https://github.com/badlogic/pi-mono/) for the full `$1`, `$@` placeholder syntax.
## Advanced: Multi-Agent Workflows
For orchestrating multi-agent chains (scout → planner → worker → reviewer) with file-based handoff and auto-continue support, see:
**[pi-foreground-chains](https://github.com/nicobailon/pi-foreground-chains)** - A separate skill that builds on interactive-shell for complex agent workflows.
## Limitations
- macOS tested, Linux experimental
- 60s rate limit between queries (configurable)
- Some TUI apps may have rendering quirks

View File

@@ -0,0 +1,89 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { formatDuration } from "./types.js";
import type { ShellSessionManager } from "./session-manager.js";
import type { InteractiveShellCoordinator } from "./runtime-coordinator.js";
export function setupBackgroundWidget(
ctx: { ui: { setWidget: Function }; hasUI?: boolean },
sessionManager: ShellSessionManager,
coordinator?: InteractiveShellCoordinator,
): (() => void) | null {
if (!ctx.hasUI) return null;
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 monitorState = coordinator?.getMonitorSessionState(s.id);
const exited = s.session.exited;
const dot = exited
? theme.fg("dim", "○")
: monitorState
? theme.fg("accent", "◆")
: 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 statusText = monitorState
? `${monitorState.status === "running" ? "monitoring" : "monitor-stopped"}${monitorState.eventCount > 0 ? ` e:${monitorState.eventCount}` : ""}`
: exited
? "exited"
: "running";
const status = exited ? theme.fg("dim", statusText) : monitorState ? theme.fg("accent", statusText) : theme.fg("success", statusText);
const duration = theme.fg("dim", formatDuration(Date.now() - s.startedAt.getTime()));
const strategy = monitorState ? theme.fg("dim", ` · ${monitorState.strategy}`) : "";
const oneLine = ` ${dot} ${id} ${truncCmd}${reason}${strategy} ${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();
return () => {
unsubscribe();
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
ctx.ui.setWidget("bg-sessions", undefined);
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,258 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@mariozechner/pi-coding-agent";
export type SpawnAgent = "pi" | "codex" | "claude" | "cursor";
export interface SpawnConfig {
defaultAgent: SpawnAgent;
shortcut: string;
commands: Record<SpawnAgent, string>;
defaultArgs: Record<SpawnAgent, string[]>;
worktree: boolean;
worktreeBaseDir?: string;
}
export interface InteractiveShellConfig {
exitAutoCloseDelay: number;
overlayWidthPercent: number;
overlayHeightPercent: number;
focusShortcut: string;
spawn: SpawnConfig;
scrollbackLines: number;
ansiReemit: boolean;
handoffPreviewEnabled: boolean;
handoffPreviewLines: number;
handoffPreviewMaxChars: number;
handoffSnapshotEnabled: boolean;
handoffSnapshotLines: number;
handoffSnapshotMaxChars: number;
transferLines: number;
transferMaxChars: number;
completionNotifyLines: number;
completionNotifyMaxChars: number;
handsFreeUpdateMode: "on-quiet" | "interval";
handsFreeUpdateInterval: number;
handsFreeQuietThreshold: number;
autoExitGracePeriod: number;
handsFreeUpdateMaxChars: number;
handsFreeMaxTotalChars: number;
minQueryIntervalSeconds: number;
}
const DEFAULT_SPAWN_CONFIG: SpawnConfig = {
defaultAgent: "pi",
shortcut: "alt+shift+p",
commands: {
pi: "pi",
codex: "codex",
claude: "claude",
cursor: "agent",
},
defaultArgs: {
pi: [],
codex: [],
claude: [],
cursor: ["--model", "composer-2-fast"],
},
worktree: false,
worktreeBaseDir: undefined,
};
const DEFAULT_CONFIG: InteractiveShellConfig = {
exitAutoCloseDelay: 10,
overlayWidthPercent: 95,
overlayHeightPercent: 60,
focusShortcut: "alt+shift+f",
spawn: DEFAULT_SPAWN_CONFIG,
scrollbackLines: 5000,
ansiReemit: true,
handoffPreviewEnabled: true,
handoffPreviewLines: 30,
handoffPreviewMaxChars: 2000,
handoffSnapshotEnabled: false,
handoffSnapshotLines: 200,
handoffSnapshotMaxChars: 12000,
transferLines: 200,
transferMaxChars: 20000,
completionNotifyLines: 50,
completionNotifyMaxChars: 5000,
handsFreeUpdateMode: "on-quiet",
handsFreeUpdateInterval: 60000,
handsFreeQuietThreshold: 8000,
autoExitGracePeriod: 15000,
handsFreeUpdateMaxChars: 1500,
handsFreeMaxTotalChars: 100000,
minQueryIntervalSeconds: 60,
};
export function loadConfig(cwd: string): InteractiveShellConfig {
const projectPath = join(cwd, ".pi", "interactive-shell.json");
const globalPath = join(getAgentDir(), "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}:`, error);
}
}
if (existsSync(projectPath)) {
try {
projectConfig = JSON.parse(readFileSync(projectPath, "utf-8"));
} catch (error) {
console.error(`Warning: Could not parse ${projectPath}:`, error);
}
}
const mergedSpawn = mergeSpawnConfig(globalConfig.spawn, projectConfig.spawn);
const merged = { ...DEFAULT_CONFIG, ...globalConfig, ...projectConfig, spawn: mergedSpawn };
return {
...merged,
exitAutoCloseDelay: clampInt(merged.exitAutoCloseDelay, DEFAULT_CONFIG.exitAutoCloseDelay, 0, 60),
overlayWidthPercent: clampPercent(merged.overlayWidthPercent, DEFAULT_CONFIG.overlayWidthPercent),
overlayHeightPercent: clampInt(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent, 20, 90),
focusShortcut: resolveShortcut(merged.focusShortcut, DEFAULT_CONFIG.focusShortcut),
spawn: mergedSpawn,
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,
),
transferLines: clampInt(merged.transferLines, DEFAULT_CONFIG.transferLines, 10, 1000),
transferMaxChars: clampInt(merged.transferMaxChars, DEFAULT_CONFIG.transferMaxChars, 1000, 100000),
completionNotifyLines: clampInt(merged.completionNotifyLines, DEFAULT_CONFIG.completionNotifyLines, 10, 500),
completionNotifyMaxChars: clampInt(merged.completionNotifyMaxChars, DEFAULT_CONFIG.completionNotifyMaxChars, 1000, 50000),
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,
),
autoExitGracePeriod: clampInt(
merged.autoExitGracePeriod,
DEFAULT_CONFIG.autoExitGracePeriod,
5000,
120000,
),
handsFreeUpdateMaxChars: clampInt(
merged.handsFreeUpdateMaxChars,
DEFAULT_CONFIG.handsFreeUpdateMaxChars,
500,
50000,
),
handsFreeMaxTotalChars: clampInt(
merged.handsFreeMaxTotalChars,
DEFAULT_CONFIG.handsFreeMaxTotalChars,
10000,
1000000,
),
minQueryIntervalSeconds: clampInt(
merged.minQueryIntervalSeconds,
DEFAULT_CONFIG.minQueryIntervalSeconds,
5,
300,
),
};
}
function mergeSpawnConfig(globalValue: unknown, projectValue: unknown): SpawnConfig {
const globalSpawn = isPlainObject(globalValue) ? globalValue : undefined;
const projectSpawn = isPlainObject(projectValue) ? projectValue : undefined;
const globalCommands = isPlainObject(globalSpawn?.commands) ? globalSpawn.commands : undefined;
const projectCommands = isPlainObject(projectSpawn?.commands) ? projectSpawn.commands : undefined;
const globalArgs = isPlainObject(globalSpawn?.defaultArgs) ? globalSpawn.defaultArgs : undefined;
const projectArgs = isPlainObject(projectSpawn?.defaultArgs) ? projectSpawn.defaultArgs : undefined;
const mergedCommands = {
pi: resolveCommand(projectCommands?.pi ?? globalCommands?.pi, DEFAULT_SPAWN_CONFIG.commands.pi),
codex: resolveCommand(projectCommands?.codex ?? globalCommands?.codex, DEFAULT_SPAWN_CONFIG.commands.codex),
claude: resolveCommand(projectCommands?.claude ?? globalCommands?.claude, DEFAULT_SPAWN_CONFIG.commands.claude),
cursor: resolveCommand(projectCommands?.cursor ?? globalCommands?.cursor, DEFAULT_SPAWN_CONFIG.commands.cursor),
};
const mergedDefaultArgs = {
pi: resolveStringArray(projectArgs?.pi ?? globalArgs?.pi, DEFAULT_SPAWN_CONFIG.defaultArgs.pi),
codex: resolveStringArray(projectArgs?.codex ?? globalArgs?.codex, DEFAULT_SPAWN_CONFIG.defaultArgs.codex),
claude: resolveStringArray(projectArgs?.claude ?? globalArgs?.claude, DEFAULT_SPAWN_CONFIG.defaultArgs.claude),
cursor: resolveStringArray(projectArgs?.cursor ?? globalArgs?.cursor, DEFAULT_SPAWN_CONFIG.defaultArgs.cursor),
};
return {
defaultAgent: resolveSpawnAgent(projectSpawn?.defaultAgent ?? globalSpawn?.defaultAgent, DEFAULT_SPAWN_CONFIG.defaultAgent),
shortcut: resolveShortcut(projectSpawn?.shortcut ?? globalSpawn?.shortcut, DEFAULT_SPAWN_CONFIG.shortcut),
commands: mergedCommands,
defaultArgs: mergedDefaultArgs,
worktree: resolveBoolean(projectSpawn?.worktree ?? globalSpawn?.worktree, DEFAULT_SPAWN_CONFIG.worktree),
worktreeBaseDir: resolveOptionalString(projectSpawn?.worktreeBaseDir ?? globalSpawn?.worktreeBaseDir),
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveSpawnAgent(value: unknown, fallback: SpawnAgent): SpawnAgent {
return value === "pi" || value === "codex" || value === "claude" || value === "cursor" ? value : fallback;
}
function resolveCommand(value: unknown, fallback: string): string {
return resolveShortcut(typeof value === "string" ? value : undefined, fallback);
}
function resolveStringArray(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) return fallback;
return value;
}
function resolveBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function resolveOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
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));
}
function resolveShortcut(value: string | undefined, fallback: string): string {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}

View File

@@ -0,0 +1,34 @@
---
description: Launch Codex CLI in overlay to fully implement an existing plan/spec document
---
Determine which prompting skill to load based on model:
- Default: Load `gpt-5-4-prompting` skill (for `gpt-5.4`)
- If user explicitly requests Codex 5.3: Load `codex-5-3-prompting` skill (for `gpt-5.3-codex`)
Also load the `codex-cli` skill. Then read the plan at `$1`.
Analyze the plan to understand: how many files are created vs modified, whether there's a prescribed implementation order or prerequisites, what existing code is referenced, and roughly how large the implementation is.
Based on the prompting skill's best practices and the plan's content, generate a comprehensive meta prompt tailored for Codex CLI. The meta prompt should instruct Codex to:
1. Read and internalize the full plan document. Identify every file to be created, every file to be modified, and any prerequisites or ordering constraints.
2. Before writing any code, read all existing files that will be modified — in full, not just the sections mentioned in the plan. Also read key files they import from or that import them, to absorb the surrounding patterns, naming conventions, and architecture.
3. If the plan specifies an implementation order or prerequisites (e.g., "extract module X before building Y"), follow that order exactly. Otherwise, implement bottom-up: shared utilities and types first, then the modules that depend on them, then integration/registration code last.
4. Implement each piece completely. No stubs, no TODOs, no placeholder comments, no "implement this later" shortcuts. Every function body, every edge case handler, every error path described in the plan must be real code.
5. Match existing code patterns exactly — same formatting, same import style, same error handling conventions, same naming. Read the surrounding codebase to absorb these patterns before writing. If the plan references patterns from specific files (e.g., "same pattern as X"), read those files and replicate the pattern faithfully.
6. Stay within scope. Do not refactor, rename, or restructure adjacent code that the plan does not mention. No "while I'm here" improvements. If something adjacent looks wrong, note it in the summary but do not touch it.
7. Keep files reasonably sized. If a file grows beyond ~500 lines, split it as the plan describes or refactor into logical sub-modules.
8. After implementing all files, do a self-review pass: re-read the plan from top to bottom and verify every requirement, every edge case, every design decision is addressed in the code. Check for: missing imports, type mismatches, unreachable code paths, inconsistent field names between modules, and any plan requirement that was overlooked.
9. Do NOT commit or push. Write a summary listing every file created or modified, what was implemented in each, and any plan ambiguities that required judgment calls.
The meta prompt should follow the prompting skill's patterns: clear system context, explicit scope and verbosity constraints, step-by-step instructions, and expected output format. Instruct Codex not to ask clarifying questions about things answerable by reading the plan or codebase — read first, then act. Keep progress updates brief and concrete (no narrating routine file reads or tool calls). Emphasize that the plan has already been thoroughly reviewed — the job is faithful execution, not second-guessing the design. Emphasize scope discipline and verification requirements per the prompting skill.
Determine the model flag:
- Default: `-m gpt-5.4`
- If user explicitly requests Codex 5.3: `-m gpt-5.3-codex`
Then launch Codex CLI in the interactive shell overlay with that meta prompt using the chosen model flag plus `-a never`.
Use `interactive_shell` with `mode: "dispatch"` for this delegated run (fire-and-forget with completion notification). Do NOT pass sandbox flags in interactive_shell. Dispatch mode only. End turn immediately. Do not poll. Wait for completion notification.
$@

View File

@@ -0,0 +1,35 @@
---
description: Launch Codex CLI in overlay to review implemented code changes (optionally against a plan)
---
Determine which prompting skill to load based on model:
- Default: Load `gpt-5-4-prompting` skill (for `gpt-5.4`)
- If user explicitly requests Codex 5.3: Load `codex-5-3-prompting` skill (for `gpt-5.3-codex`)
Also load the `codex-cli` skill. Then determine the review scope:
- If `$1` looks like a file path (contains `/` or ends in `.md`): read it as the plan/spec these changes were based on. The diff scope is uncommitted changes vs HEAD, or if clean, the current branch vs main.
- Otherwise: no plan file. Diff scope is the same. Treat all of `$@` as additional review context or focus areas.
Run the appropriate git diff to identify which files changed and how many lines are involved. This context helps you generate a better-calibrated meta prompt.
Based on the prompting skill's best practices, the diff scope, and the optional plan, generate a comprehensive meta prompt tailored for Codex CLI. The meta prompt should instruct Codex to:
1. Identify all changed files via git diff, then read every changed file in full — not just the diff hunks. For each changed file, also read the files it imports from and key files that depend on it, to understand integration points and downstream effects.
2. If a plan/spec was provided, read it and verify the implementation is complete — every requirement addressed, no steps skipped, nothing invented beyond scope, no partial stubs left behind.
3. Review each changed file for: bugs, logic errors, race conditions, resource leaks (timers, event listeners, file handles, unclosed connections), null/undefined hazards, off-by-one errors, error handling gaps, type mismatches, dead code, unused imports/variables/parameters, unnecessary complexity, and inconsistency with surrounding code patterns and naming conventions.
4. Trace key code paths end-to-end across function and file boundaries — verify data flows, state transitions, error propagation, and cleanup ordering. Don't evaluate functions in isolation.
5. Check for missing or inadequate tests, stale documentation, and missing changelog entries.
6. Fix every issue found with direct code edits. Keep fixes scoped to the actual issues identified — do not expand into refactoring or restructuring code that wasn't flagged in the review. If adjacent code looks problematic, note it in the summary but don't touch it.
7. After all fixes, write a clear summary listing what was found, what was fixed, and any remaining concerns that require human judgment.
The meta prompt should follow the prompting skill's patterns: clear system context, explicit scope and verbosity constraints, step-by-step instructions, and expected output format. Instruct Codex not to ask clarifying questions — if intent is unclear, read the surrounding code for context instead of asking. Keep progress updates brief and concrete (no narrating routine file reads or tool calls). Emphasize thoroughness — read the actual code deeply before making judgments, question every assumption, and never rubber-stamp. Emphasize scope discipline and verification requirements per the prompting skill.
Determine the model flag:
- Default: `-m gpt-5.4`
- If user explicitly requests Codex 5.3: `-m gpt-5.3-codex`
Then launch Codex CLI in the interactive shell overlay with that meta prompt using the chosen model flag plus `-a never`.
Use `interactive_shell` with `mode: "dispatch"` for this delegated run (fire-and-forget with completion notification). Do NOT pass sandbox flags in interactive_shell. Dispatch mode only. End turn immediately. Do not poll. Wait for completion notification.
$@

View File

@@ -0,0 +1,29 @@
---
description: Launch Codex CLI in overlay to review an implementation plan against the codebase
---
Determine which prompting skill to load based on model:
- Default: Load `gpt-5-4-prompting` skill (for `gpt-5.4`)
- If user explicitly requests Codex 5.3: Load `codex-5-3-prompting` skill (for `gpt-5.3-codex`)
Also load the `codex-cli` skill. Then read the plan at `$1`.
Based on the prompting skill's best practices and the plan's content, generate a comprehensive meta prompt tailored for Codex CLI. The meta prompt should instruct Codex to:
1. Read and internalize the full plan. Then read every codebase file the plan references — in full, not just the sections mentioned. Also read key files adjacent to those (imports, dependents) to understand the real state of the code the plan targets.
2. Systematically review the plan against what the code actually looks like, not what the plan assumes it looks like.
3. Verify every assumption, file path, API shape, data flow, and integration point mentioned in the plan against the actual codebase.
4. Check that the plan's approach is logically sound, complete, and accounts for edge cases.
5. Identify any gaps, contradictions, incorrect assumptions, or missing steps.
6. Make targeted edits to the plan file to fix issues found, adding inline notes where changes were made. Fix what's wrong — do not restructure or rewrite sections that are correct.
The meta prompt should follow the prompting skill's patterns (clear system context, explicit constraints, step-by-step instructions, expected output format). Instruct Codex not to ask clarifying questions — read the codebase to resolve ambiguities instead of asking. Keep progress updates brief and concrete. Emphasize scope discipline and verification requirements per the prompting skill.
Determine the model flag:
- Default: `-m gpt-5.4`
- If user explicitly requests Codex 5.3: `-m gpt-5.3-codex`
Then launch Codex CLI in the interactive shell overlay with that meta prompt using the chosen model flag plus `-a never`.
Use `interactive_shell` with `mode: "dispatch"` for this delegated run (fire-and-forget with completion notification). Do NOT pass sandbox flags in interactive_shell. Dispatch mode only. End turn immediately. Do not poll. Wait for completion notification.
$@

View File

@@ -0,0 +1,161 @@
---
name: codex-5-3-prompting
description: How to write system prompts and instructions for GPT-5.3-Codex. Use when constructing or tuning prompts targeting Codex 5.3.
---
# GPT-5.3-Codex Prompting Guide
GPT-5.3-Codex is fast, capable, and eager. It moves quickly and will skip reading, over-refactor, and drift scope if prompts aren't tight. Explicit constraints matter more than with GPT-5.2-Codex. Include the following blocks as needed when constructing system prompts.
## Output shape
Always include. Controls verbosity and response structure.
```
<output_verbosity_spec>
- Default: 3-6 sentences or <=5 bullets for typical answers.
- Simple yes/no questions: <=2 sentences.
- Complex multi-step or multi-file tasks:
- 1 short overview paragraph
- then <=5 bullets tagged: What changed, Where, Risks, Next steps, Open questions.
- Avoid long narrative paragraphs; prefer compact bullets and short sections.
- Do not rephrase the user's request unless it changes semantics.
</output_verbosity_spec>
```
## Scope constraints
Always include. GPT-5.3-Codex will add features, refactor adjacent code, and invent UI elements if you don't fence it in.
```
<design_and_scope_constraints>
- Explore any existing design systems and understand them deeply.
- Implement EXACTLY and ONLY what the user requests.
- No extra features, no added components, no UX embellishments.
- Style aligned to the design system at hand.
- Do NOT invent colors, shadows, tokens, animations, or new UI elements unless requested or necessary.
- If any instruction is ambiguous, choose the simplest valid interpretation.
</design_and_scope_constraints>
```
## Context loading
Always include. GPT-5.3-Codex skips reading and starts writing if you don't force it.
```
<context_loading>
- Read ALL files that will be modified -- in full, not just the sections mentioned in the task.
- Also read key files they import from or that depend on them.
- Absorb surrounding patterns, naming conventions, error handling style, and architecture before writing any code.
- Do not ask clarifying questions about things that are answerable by reading the codebase.
</context_loading>
```
## Plan-first mode
Include for multi-file work, large refactors, or any task with ordering dependencies.
```
<plan_first>
- Before writing any code, produce a brief implementation plan:
- Files to create vs. modify
- Implementation order and prerequisites
- Key design decisions and edge cases
- Acceptance criteria for "done"
- Get the plan right first. Then implement step by step following the plan.
- If the plan is provided externally, follow it faithfully -- the job is execution, not second-guessing the design.
</plan_first>
```
## Long-context handling
Include when inputs exceed ~10k tokens (multi-chapter docs, long threads, multiple PDFs).
```
<long_context_handling>
- For inputs longer than ~10k tokens:
- First, produce a short internal outline of the key sections relevant to the task.
- Re-state the constraints explicitly before answering.
- Anchor claims to sections ("In the 'Data Retention' section...") rather than speaking generically.
- If the answer depends on fine details (dates, thresholds, clauses), quote or paraphrase them.
</long_context_handling>
```
## Uncertainty and ambiguity
Include when the task involves underspecified requirements or hallucination-prone domains.
```
<uncertainty_and_ambiguity>
- If the question is ambiguous or underspecified:
- Ask up to 1-3 precise clarifying questions, OR
- Present 2-3 plausible interpretations with clearly labeled assumptions.
- Never fabricate exact figures, line numbers, or external references when uncertain.
- When unsure, prefer "Based on the provided context..." over absolute claims.
</uncertainty_and_ambiguity>
```
## User updates
Include for agentic / long-running tasks.
```
<user_updates_spec>
- Send brief updates (1-2 sentences) only when:
- You start a new major phase of work, or
- You discover something that changes the plan.
- Avoid narrating routine tool calls ("reading file...", "running tests...").
- Each update must include at least one concrete outcome ("Found X", "Confirmed Y", "Updated Z").
- Do not expand the task beyond what was asked; if you notice new work, call it out as optional.
</user_updates_spec>
```
## Tool usage
Include when the prompt involves tool-calling agents.
```
<tool_usage_rules>
- Prefer tools over internal knowledge whenever:
- You need fresh or user-specific data (tickets, orders, configs, logs).
- You reference specific IDs, URLs, or document titles.
- Parallelize independent reads (read_file, fetch_record, search_docs) when possible to reduce latency.
- After any write/update tool call, briefly restate:
- What changed
- Where (ID or path)
- Any follow-up validation performed
</tool_usage_rules>
```
## Reasoning effort
Set `model_reasoning_effort` via Codex CLI: `-c model_reasoning_effort="high"`
| Task type | Effort |
|---|---|
| Simple code generation, formatting | `low` or `medium` |
| Standard implementation from clear specs | `high` |
| Complex refactors, plan review, architecture | `xhigh` |
| Code review (thorough) | `high` or `xhigh` |
## Backwards compatibility hedging
GPT-5.3-Codex has a strong tendency to preserve old patterns, add compatibility shims, and provide fallback code "just in case" -- even when explicitly told not to worry about backwards compatibility. Vague instructions like "don't worry about backwards compatibility" get interpreted weakly; the model may still hedge.
Use **"cutover"** to signal a clean, irreversible break. It's a precise industry term that conveys finality and intentional deprecation -- no dual-support phase, no gradual migration, no preserving old behavior.
Instead of:
> "Rewrite this and don't worry about backwards compatibility"
Say:
> "This is a cutover. No backwards compatibility. Rewrite using only Python 3.12+ features and current best practices. Do not preserve legacy code, polyfills, or deprecated patterns."
## Quick reference
- **Force reading first.** "Read all necessary files before you ask any dumb question."
- **Use plan mode.** Draft the full task with acceptance criteria before implementing.
- **Steer aggressively mid-task.** GPT-5.3-Codex handles redirects without losing context. Be direct: "Stop. Fix the actual cause." / "Simplest valid implementation only."
- **Constrain scope hard.** GPT-5.3-Codex will refactor aggressively if you don't fence it in.
- **Watch context burn.** Faster model = faster context consumption. Start fresh at ~40%.
- **Use domain jargon.** "Cutover," "golden-path," "no fallbacks," "domain split" get cleaner, faster responses.
- **Download libraries locally.** Tell it to read them for better context than relying on training data.

View File

@@ -0,0 +1,130 @@
---
name: codex-cli
description: OpenAI Codex CLI reference. Use when running codex in interactive_shell overlay or when user asks about codex CLI options.
---
# Codex CLI (OpenAI)
## Commands
| Command | Description |
|---------|-------------|
| `codex` | Start interactive TUI |
| `codex "prompt"` | TUI with initial prompt |
| `codex exec "prompt"` | Non-interactive (headless), streams to stdout. Supports `--output-schema <file>` for structured JSON output |
| `codex e "prompt"` | Shorthand for exec |
| `codex login` | Authenticate (OAuth, device auth, or API key) |
| `codex login status` | Show auth mode |
| `codex logout` | Remove credentials |
| `codex mcp` | Manage MCP servers |
| `codex completion` | Generate shell completions |
## Key Flags
| Flag | Description |
|------|-------------|
| `-m, --model <model>` | Switch model (prefer `gpt-5.5`) |
| `-c <key=value>` | Override config.toml values (dotted paths, parsed as TOML) |
| `-p, --profile <name>` | Use config profile from config.toml |
| `-s, --sandbox <mode>` | Sandbox policy: `read-only`, `workspace-write`, `danger-full-access` |
| `-a, --ask-for-approval <policy>` | `untrusted`, `on-failure`, `on-request`, `never` |
| `--full-auto` | Alias for `-a on-request --sandbox workspace-write` |
| `--search` | Enable live web search tool |
| `-i, --image <file>` | Attach image(s) to initial prompt |
| `--add-dir <dir>` | Additional writable directories |
| `-C, --cd <dir>` | Set working root directory |
| `--no-alt-screen` | Inline mode (preserve terminal scrollback) |
## Sandbox Modes
- `read-only` - Can only read files
- `workspace-write` - Can write to workspace
- `danger-full-access` - Full system access (use with caution)
## Features
- **Image inputs** - Accepts screenshots and design specs
- **Image generation (gpt-image-2)** - Generate images via natural language or explicit invocation
- **Code review** - Reviews changes before commit
- **Web search** - Can search for information
- **MCP integration** - Third-party tool support
## Image Generation (gpt-image-2)
Codex CLI can generate images using OpenAI's **gpt-image-2** - the latest cutting-edge image model with superior realism, prompt adherence, and accurate text rendering in images. It can produce full high-fidelity design mockups for web pages and apps with unprecedented accuracy and control.
### How to Invoke
#### Natural Language (Recommended)
Just describe what you want naturally:
```bash
codex "Generate a clean app icon for a fitness tracker, flat design, 512x512"
codex "Create a hero banner for a SaaS landing page showing a dashboard with dark mode"
codex -i screenshot.png "Edit this screenshot to make the button green and add a tooltip"
```
#### Explicit Skill Invocation
Include `$imagegen` anywhere in your prompt to force the image-generation tool. This is a Codex keyword, not a shell variable, so shell examples use single quotes to keep it literal.
```bash
codex 'Make a pixel-art sprite sheet for a platformer game $imagegen'
codex 'Generate a logo for my coffee shop $imagegen'
```
Codex will generate the image(s), display them inline in the terminal (or save them locally). You can iterate on them, attach them to future prompts, or use them in your codebase.
### Tips
- **Image editing / iteration**: Attach a reference image (screenshot, wireframe, mockup) to your prompt. Codex handles multimodal input natively.
```bash
codex -i wireframe.png "Turn this wireframe into a polished UI mockup"
codex -i design.png "Generate code for this design"
```
- **Usage & limits**: Images count against your regular Codex usage quota and consume it 3-5x faster than text-only turns (depending on size/quality).
- **Heavy/batch work**: For production pipelines, set `OPENAI_API_KEY` in your shell and tell Codex to call the OpenAI Images API directly. It will then use `gpt-image-2` with full API pricing and options.
- **No config needed**: Image generation is enabled by default. Older experimental flags like `codex features enable image_generation` are no longer required.
## Config
Config file: `~/.codex/config.toml`
Key config values (set in file or override with `-c`):
- `model` -- model name (prefer `gpt-5.5`)
- `model_reasoning_effort` -- `low`, `medium`, `high`, `xhigh`
- `model_reasoning_summary` -- `detailed`, `concise`, `none`
- `model_verbosity` -- `low`, `medium`, `high`
- `profile` -- default profile name
- `tool_output_token_limit` -- max tokens per tool output
Define profiles for different projects/modes with `[profiles.<name>]` sections. Override at runtime with `-p <name>` or `-c model_reasoning_effort="high"`.
## In interactive_shell
Do NOT pass `-s` / `--sandbox` flags. Codex's `read-only` and `workspace-write` sandbox modes apply OS-level filesystem restrictions that break basic shell operations inside the PTY -- zsh can't even create temp files for here-documents, so every write attempt fails with "operation not permitted." The interactive shell overlay already provides supervision (user watches in real-time, Ctrl+Q to kill, Ctrl+T to transfer output), making Codex's sandbox redundant.
Prefer `gpt-5.5` for Codex CLI work. For users with a default profile configured to `gpt-5.5`, just run `codex "prompt"` to use those defaults -- no model or profile flags needed.
For delegated fire-and-forget runs, prefer `mode: "dispatch"` so the agent is notified automatically when Codex completes.
```typescript
// Delegated run with completion notification (recommended default)
interactive_shell({
command: 'codex "Review this codebase for security issues"',
mode: "dispatch"
})
// Override reasoning effort for a single delegated run
interactive_shell({
command: 'codex -c model_reasoning_effort="xhigh" "Complex refactor task"',
mode: "dispatch"
})
// Headless - use bash instead
bash({ command: 'codex exec "summarize the repo"' })
```

View File

@@ -0,0 +1,53 @@
---
name: cursor-cli
description: Cursor CLI reference. Use when running Cursor in interactive_shell overlay or when user asks about Cursor CLI options.
---
# Cursor CLI
## Commands
| Command | Description |
|---------|-------------|
| `agent` | Start interactive Cursor session |
| `agent "prompt"` | Interactive session with initial prompt |
| `agent -p "prompt"` | Non-interactive print mode |
| `agent ls` | List previous chats |
| `agent resume` | Resume latest chat |
| `agent --continue` | Continue previous session |
| `agent --resume "chat-id"` | Resume a specific chat |
## Key Flags
| Flag | Description |
|------|-------------|
| `--mode plan` / `--plan` | Plan mode (clarify before coding) |
| `--mode ask` | Ask mode (read-only exploration) |
| `--model <model>` | Model override |
| `--sandbox <enabled|disabled>` | Toggle sandbox behavior |
| `--output-format text` | Output format for print mode workflows |
## Mode Notes
- **Interactive mode** (`agent`, `agent "prompt"`) is the right fit for `interactive_shell` overlays.
- **Print mode** (`agent -p`) is non-interactive and better suited to direct shell/batch usage.
## In interactive_shell
Use structured spawn when you want the extension's shared spawn resolver/defaults/worktree support:
```typescript
interactive_shell({ spawn: { agent: "cursor" }, mode: "interactive" })
interactive_shell({ spawn: { agent: "cursor", prompt: "Review the diffs" }, mode: "dispatch" })
interactive_shell({ spawn: { agent: "cursor", worktree: true }, mode: "hands-free" })
```
Structured spawn launches Cursor via the configured `spawn.commands.cursor` executable (default: `agent`) and appends prompt text as Cursor's native interactive startup form (`agent "prompt"`). By default, spawn args include `--model composer-2-fast`, which selects Cursor's Composer 2 Fast model explicitly.
Cursor remains **fresh/worktree only** in structured spawn. `fork` is Pi-only.
For non-interactive print-mode tasks, prefer direct shell usage:
```typescript
bash({ command: 'agent -p "review these changes for security issues" --output-format text' })
```

View File

@@ -0,0 +1,202 @@
---
name: gpt-5-4-prompting
description: How to write system prompts and instructions for GPT-5.4. Use when constructing or tuning prompts targeting GPT-5.4.
---
# GPT-5.4 Prompting Guide
GPT-5.4 unifies reasoning, coding, and agentic capabilities into a single frontier model. It's extremely persistent, highly token-efficient, and delivers more human-like outputs than its predecessors. However, it has new failure modes: it moves fast without solid plans, expands scope aggressively, and can prematurely declare tasks complete—sometimes falsely claiming success. Prompts must account for these behaviors.
## Output shape
Always include.
```
<output_verbosity_spec>
- Default: 3-6 sentences or <=5 bullets for typical answers.
- Simple yes/no questions: <=2 sentences.
- Complex multi-step or multi-file tasks:
- 1 short overview paragraph
- then <=5 bullets tagged: What changed, Where, Risks, Next steps, Open questions.
- Avoid long narrative paragraphs; prefer compact bullets and short sections.
- Do not rephrase the user's request unless it changes semantics.
</output_verbosity_spec>
```
## Scope constraints
Critical. GPT-5.4's primary failure mode is scope expansion—it adds features, refactors beyond the ask, and "helpfully" extends tasks. Fence it in hard.
```
<design_and_scope_constraints>
- Implement EXACTLY and ONLY what the user requests. Nothing more.
- No extra features, no "while I'm here" improvements, no UX embellishments.
- Do NOT expand the task scope under any circumstances.
- If you notice adjacent issues or opportunities, note them in your summary but DO NOT act on them.
- If any instruction is ambiguous, choose the simplest valid interpretation.
- Style aligned to the existing design system. Do not invent new patterns.
- Do NOT invent colors, shadows, tokens, animations, or new UI elements unless explicitly requested.
</design_and_scope_constraints>
```
## Verification requirements
Critical. GPT-5.4 can declare tasks complete prematurely or claim success when the implementation is incorrect. Force explicit verification.
```
<verification_requirements>
- Before declaring any task complete, perform explicit verification:
- Re-read the original requirements
- Check that every requirement is addressed in the actual code
- Run tests or validation steps if available
- Confirm the implementation actually works, don't assume
- Do NOT claim success based on intent—verify actual outcomes.
- If you cannot verify (no tests, can't run code), say so explicitly.
- When reporting completion, include concrete evidence: test results, verified file contents, or explicit acknowledgment of what couldn't be verified.
- If something failed or was skipped, say so clearly. Do not obscure failures.
</verification_requirements>
```
## Context loading
Always include. GPT-5.4 is faster and may skip reading in favor of acting. Force thoroughness.
```
<context_loading>
- Read ALL files that will be modified—in full, not just the sections mentioned in the task.
- Also read key files they import from or that depend on them.
- Absorb surrounding patterns, naming conventions, error handling style, and architecture before writing any code.
- Do not ask clarifying questions about things that are answerable by reading the codebase.
- If modifying existing code, understand the full context before making changes.
</context_loading>
```
## Plan-first mode
Include for multi-file work, refactors, or tasks with ordering dependencies. GPT-5.4 produces good natural-language plans but may skip validation steps.
```
<plan_first>
- Before writing any code, produce a brief implementation plan:
- Files to create vs. modify
- Implementation order and prerequisites
- Key design decisions and edge cases
- Acceptance criteria for "done"
- How you will verify each step
- Execute the plan step by step. After each step, verify it worked before proceeding.
- If the plan is provided externally, follow it faithfully—the job is execution, not second-guessing.
- Do NOT skip verification steps even if you're confident.
</plan_first>
```
## Long-context handling
GPT-5.4 supports up to 1M tokens, but accuracy degrades beyond ~512K. Handle long inputs carefully.
```
<long_context_handling>
- For inputs longer than ~10k tokens:
- First, produce a short internal outline of the key sections relevant to the task.
- Re-state the constraints explicitly before answering.
- Anchor claims to sections ("In the 'Data Retention' section...") rather than speaking generically.
- If the answer depends on fine details (dates, thresholds, clauses), quote or paraphrase them.
- For very long contexts (200K+ tokens):
- Be extra vigilant about accuracy—retrieval quality degrades.
- Cross-reference claims against multiple sections.
- Prefer citing specific locations over making sweeping statements.
</long_context_handling>
```
## Tool usage
```
<tool_usage_rules>
- Prefer tools over internal knowledge whenever:
- You need fresh or user-specific data (tickets, orders, configs, logs).
- You reference specific IDs, URLs, or document titles.
- Parallelize independent tool calls when possible to reduce latency.
- After any write/update tool call, verify the outcome—do not assume success.
- After any write/update tool call, briefly restate:
- What changed
- Where (ID or path)
- Verification performed or why verification was skipped
</tool_usage_rules>
```
## Backwards compatibility hedging
GPT-5.4 tends to preserve old patterns and add compatibility shims. Use **"cutover"** to signal a clean break.
Instead of:
> "Rewrite this and don't worry about backwards compatibility"
Say:
> "This is a cutover. No backwards compatibility. Rewrite using only Python 3.12+ features and current best practices. Do not preserve legacy code, polyfills, or deprecated patterns."
## Quick reference
- **Constrain scope aggressively.** GPT-5.4 expands tasks beyond the ask. "ONLY what is requested, nothing more."
- **Force verification.** Don't trust "done"—require evidence. "Verify before claiming complete."
- **Use cutover language.** "Cutover," "no fallbacks," "exactly as specified" get cleaner results.
- **Plan mode helps.** Explicit plan-first prompts ensure verification steps.
- **Watch for false success claims.** In agent harnesses, add explicit validation steps. Don't let it self-report completion.
- **Steer mid-task.** GPT-5.4 handles redirects well. Be direct: "Stop. That's out of scope." / "Verify that actually worked."
- **Use domain jargon.** "Cutover," "golden-path," "no fallbacks," "domain split," "exactly as specified" trigger precise behavior.
- **Long context degrades.** Above ~512K tokens, cross-reference claims and cite specific sections.
- **Token efficiency is real.** 5.4 uses fewer tokens per problem—but verify it didn't skip steps to get there.
## Example: implementation task prompt
```
<system>
You are implementing a feature in an existing codebase. Follow these rules strictly.
<design_and_scope_constraints>
- Implement EXACTLY and ONLY what the user requests. Nothing more.
- No extra features, no "while I'm here" improvements.
- If you notice adjacent issues, note them in your summary but DO NOT act on them.
</design_and_scope_constraints>
<context_loading>
- Read ALL files that will be modified—in full.
- Also read key files they import from or depend on.
- Absorb patterns before writing any code.
</context_loading>
<verification_requirements>
- Before declaring complete, verify each requirement is addressed in actual code.
- Run tests if available. If not, state what couldn't be verified.
- Include concrete evidence of completion in your summary.
</verification_requirements>
<output_verbosity_spec>
- Brief updates only on major phases or blockers.
- Final summary: What changed, Where, Risks, Next steps.
</output_verbosity_spec>
</system>
```
## Example: code review prompt
```
<system>
You are reviewing code changes. Be thorough but stay in scope.
<context_loading>
- Read every changed file in full, not just the diff hunks.
- Also read files they import from and key dependents.
</context_loading>
<review_scope>
- Review for: bugs, logic errors, race conditions, resource leaks, null hazards, error handling gaps, type mismatches, dead code, unused imports, pattern inconsistencies.
- Fix issues you find with direct code edits.
- Do NOT refactor or restructure code that wasn't flagged in the review.
- If adjacent code looks problematic, note it but don't touch it.
</review_scope>
<verification_requirements>
- After fixes, verify the code still works. Run tests if available.
- In your summary, list what was found, what was fixed, and what couldn't be verified.
</verification_requirements>
</system>
```

View File

@@ -0,0 +1,92 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import type { InteractiveShellConfig } from "./config.js";
import type { InteractiveShellOptions, InteractiveShellResult } from "./types.js";
import type { PtyTerminalSession } from "./pty-session.js";
export function captureCompletionOutput(
session: PtyTerminalSession,
config: InteractiveShellConfig,
): InteractiveShellResult["completionOutput"] {
const result = session.getTailLines({
lines: config.completionNotifyLines,
ansi: false,
maxChars: config.completionNotifyMaxChars,
});
return {
lines: result.lines,
totalLines: result.totalLinesInBuffer,
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
};
}
export function captureTransferOutput(
session: PtyTerminalSession,
config: InteractiveShellConfig,
): InteractiveShellResult["transferred"] {
const result = session.getTailLines({
lines: config.transferLines,
ansi: false,
maxChars: config.transferMaxChars,
});
return {
lines: result.lines,
totalLines: result.totalLinesInBuffer,
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
};
}
export function maybeBuildHandoffPreview(
session: PtyTerminalSession,
when: "exit" | "detach" | "kill" | "timeout" | "transfer",
config: InteractiveShellConfig,
overrides?: Pick<InteractiveShellOptions, "handoffPreviewEnabled" | "handoffPreviewLines" | "handoffPreviewMaxChars">,
): InteractiveShellResult["handoffPreview"] | undefined {
const enabled = overrides?.handoffPreviewEnabled ?? config.handoffPreviewEnabled;
if (!enabled) return undefined;
const lines = overrides?.handoffPreviewLines ?? config.handoffPreviewLines;
const maxChars = overrides?.handoffPreviewMaxChars ?? config.handoffPreviewMaxChars;
if (lines <= 0 || maxChars <= 0) return undefined;
const result = session.getTailLines({ lines, ansi: false, maxChars });
return { type: "tail", when, lines: result.lines };
}
export function maybeWriteHandoffSnapshot(
session: PtyTerminalSession,
when: "exit" | "detach" | "kill" | "timeout" | "transfer",
config: InteractiveShellConfig,
context: { command: string; cwd?: string },
overrides?: Pick<InteractiveShellOptions, "handoffSnapshotEnabled" | "handoffSnapshotLines" | "handoffSnapshotMaxChars">,
): InteractiveShellResult["handoff"] | undefined {
const enabled = overrides?.handoffSnapshotEnabled ?? config.handoffSnapshotEnabled;
if (!enabled) return undefined;
const lines = overrides?.handoffSnapshotLines ?? config.handoffSnapshotLines;
const maxChars = overrides?.handoffSnapshotMaxChars ?? config.handoffSnapshotMaxChars;
if (lines <= 0 || maxChars <= 0) return undefined;
const baseDir = join(getAgentDir(), "cache", "interactive-shell");
mkdirSync(baseDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const pid = session.pid;
const filename = `snapshot-${timestamp}-pid${pid}.log`;
const transcriptPath = join(baseDir, filename);
const tailResult = session.getTailLines({
lines,
ansi: config.ansiReemit,
maxChars,
});
const header = [
`# interactive-shell snapshot (${when})`,
`time: ${new Date().toISOString()}`,
`command: ${context.command}`,
`cwd: ${context.cwd ?? ""}`,
`pid: ${pid}`,
`exitCode: ${session.exitCode ?? ""}`,
`signal: ${session.signal ?? ""}`,
`lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
"",
].join("\n");
writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
return { type: "snapshot", when, transcriptPath, linesWritten: tailResult.lines.length };
}

View File

@@ -0,0 +1,397 @@
import { stripVTControlCharacters } from "node:util";
import type { PtyTerminalSession } from "./pty-session.js";
import type { InteractiveShellConfig } from "./config.js";
export interface MonitorMatchInfo {
strategy: "stream" | "poll-diff" | "file-watch";
triggerId: string;
eventType: string;
matchedText: string;
lineOrDiff: string;
stream: "pty";
}
export interface MonitorTriggerMatcher {
id: string;
cooldownMs?: number;
match: (input: string) => string | undefined;
}
export interface MonitorRuntimeConfig {
strategy: "stream" | "poll-diff" | "file-watch";
triggers: MonitorTriggerMatcher[];
pollIntervalMs: number;
dedupeExactLine: boolean;
cooldownMs?: number;
}
/** Runtime options for monitoring a headless dispatch session. */
export interface HeadlessMonitorOptions {
autoExitOnQuiet: boolean;
quietThreshold: number;
gracePeriod?: number;
timeout?: number;
monitor?: MonitorRuntimeConfig;
onMonitorEvent?: (event: MonitorMatchInfo) => void | Promise<void>;
/** Original session start time in ms since epoch, preserved when a foreground session moves headless. */
startedAt?: number;
}
/** Completion payload emitted when a headless dispatch session finishes. */
export interface HeadlessCompletionInfo {
exitCode: number | null;
signal?: number;
timedOut?: boolean;
cancelled?: boolean;
completionOutput?: {
lines: string[];
totalLines: number;
truncated: boolean;
};
}
export class HeadlessDispatchMonitor {
readonly startTime: number;
private _disposed = false;
private quietTimer: ReturnType<typeof setTimeout> | null = null;
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
private pollInitialized = false;
private lastPollSnapshot = "";
private pollReadOffset = 0;
private result: HeadlessCompletionInfo | undefined;
private completeCallbacks: Array<() => void> = [];
private unsubData: (() => void) | null = null;
private unsubExit: (() => void) | null = null;
private monitorLineBuffer = "";
private emittedMonitorKeys = new Set<string>();
private triggerLastEmitAt = new Map<string, number>();
get disposed(): boolean { return this._disposed; }
constructor(
private session: PtyTerminalSession,
private config: InteractiveShellConfig,
private options: HeadlessMonitorOptions,
private onComplete: (info: HeadlessCompletionInfo) => void,
) {
this.startTime = options.startedAt ?? Date.now();
this.subscribe();
if (options.autoExitOnQuiet) {
this.resetQuietTimer();
}
if (options.timeout && options.timeout > 0) {
this.timeoutTimer = setTimeout(() => {
this.handleCompletion(null, undefined, true);
}, options.timeout);
}
if (options.monitor?.strategy === "poll-diff") {
this.startPollTimer();
}
if (session.exited) {
queueMicrotask(() => {
if (!this._disposed) {
this.handleCompletion(session.exitCode, session.signal);
}
});
}
}
private subscribe(): void {
this.unsubscribe();
this.unsubData = this.session.addDataListener((data) => {
const visible = stripVTControlCharacters(data);
if (this.options.autoExitOnQuiet && visible.trim().length > 0) {
this.resetQuietTimer();
}
if (this.options.monitor?.strategy !== "poll-diff" && this.options.onMonitorEvent) {
this.processMonitorData(visible, false);
}
});
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 processMonitorData(visible: string, flushTrailing: boolean): void {
if (!visible && !flushTrailing) return;
const combined = this.monitorLineBuffer + visible;
const parts = combined.split(/\r\n|\n|\r/g);
if (flushTrailing) {
this.monitorLineBuffer = "";
} else {
this.monitorLineBuffer = parts.pop() ?? "";
}
for (const line of parts) {
if (!line) continue;
this.emitStreamMatches(line);
}
}
private emitStreamMatches(line: string): void {
const monitor = this.options.monitor;
if (!monitor || monitor.strategy === "poll-diff") return;
for (const trigger of monitor.triggers) {
const matchedText = trigger.match(line);
if (!matchedText) continue;
if (!this.canEmitTrigger(trigger.id, trigger.cooldownMs)) continue;
if (!this.shouldEmitUnique(trigger.id, line)) continue;
this.emitMonitorEvent({
strategy: monitor.strategy,
triggerId: trigger.id,
eventType: trigger.id,
matchedText,
lineOrDiff: line,
stream: "pty",
});
}
}
private startPollTimer(): void {
const monitor = this.options.monitor;
if (!monitor || monitor.strategy !== "poll-diff") return;
const intervalMs = Math.max(250, Math.trunc(monitor.pollIntervalMs || 5000));
this.pollTimer = setInterval(() => {
void this.processPollTick();
}, intervalMs);
}
private stopPollTimer(): void {
if (!this.pollTimer) return;
clearInterval(this.pollTimer);
this.pollTimer = null;
}
private async processPollTick(): Promise<void> {
if (this._disposed || this.pollInFlight) return;
const monitor = this.options.monitor;
if (!monitor || monitor.strategy !== "poll-diff") return;
this.pollInFlight = true;
try {
const raw = this.session.getRawStream({ sinceLast: false, stripAnsi: true });
if (this.pollReadOffset > raw.length) {
this.pollReadOffset = raw.length;
}
const sample = normalizeMonitorSnapshot(raw.slice(this.pollReadOffset));
this.pollReadOffset = raw.length;
if (!this.pollInitialized) {
this.lastPollSnapshot = sample;
this.pollInitialized = true;
return;
}
if (sample === this.lastPollSnapshot) return;
const previous = this.lastPollSnapshot;
this.lastPollSnapshot = sample;
const diffSummary = summarizeDiff(previous, sample);
for (const trigger of monitor.triggers) {
const matchedText = trigger.match(sample);
if (!matchedText) continue;
if (!this.canEmitTrigger(trigger.id, trigger.cooldownMs)) continue;
if (!this.shouldEmitUnique(trigger.id, diffSummary)) continue;
this.emitMonitorEvent({
strategy: "poll-diff",
triggerId: trigger.id,
eventType: trigger.id,
matchedText,
lineOrDiff: diffSummary,
stream: "pty",
});
}
} catch (error) {
console.error("interactive-shell: poll-diff tick error:", error);
} finally {
this.pollInFlight = false;
}
}
private shouldEmitUnique(triggerId: string, lineOrDiff: string): boolean {
const monitor = this.options.monitor;
if (!monitor || monitor.dedupeExactLine === false) return true;
const key = `${triggerId}\u0000${lineOrDiff}`;
if (this.emittedMonitorKeys.has(key)) return false;
this.emittedMonitorKeys.add(key);
return true;
}
private canEmitTrigger(triggerId: string, triggerCooldownMs?: number): boolean {
const monitor = this.options.monitor;
if (!monitor) return true;
const cooldown = triggerCooldownMs ?? monitor.cooldownMs;
if (!cooldown || cooldown <= 0) return true;
const now = Date.now();
const last = this.triggerLastEmitAt.get(triggerId) ?? 0;
if (now - last < cooldown) return false;
this.triggerLastEmitAt.set(triggerId, now);
return true;
}
private emitMonitorEvent(event: MonitorMatchInfo): void {
try {
const maybePromise = this.options.onMonitorEvent?.(event);
if (maybePromise && typeof (maybePromise as Promise<unknown>).then === "function") {
void (maybePromise as Promise<unknown>).catch((error) => {
console.error("interactive-shell: monitor event callback error:", error);
});
}
} catch (error) {
console.error("interactive-shell: monitor event callback error:", error);
}
}
private resetQuietTimer(): void {
this.stopQuietTimer();
this.quietTimer = setTimeout(() => {
this.quietTimer = null;
if (!this._disposed && this.options.autoExitOnQuiet) {
const gracePeriod = this.options.gracePeriod ?? this.config.autoExitGracePeriod;
if (Date.now() - this.startTime < gracePeriod) {
this.resetQuietTimer();
return;
}
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 {
// Session terminal may already be disposed during completion — safe to return empty
return { lines: [], totalLines: 0, truncated: false };
}
}
private handleCompletion(exitCode: number | null, signal?: number, timedOut?: boolean, cancelled?: boolean): void {
if (this._disposed) return;
if (this.options.monitor?.strategy !== "poll-diff" && this.options.onMonitorEvent) {
this.processMonitorData("", true);
}
this._disposed = true;
this.stopQuietTimer();
this.stopPollTimer();
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;
if (this.options.monitor?.strategy !== "poll-diff" && this.options.onMonitorEvent) {
this.processMonitorData("", true);
}
this._disposed = true;
this.stopQuietTimer();
this.stopPollTimer();
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 (error) {
console.error("interactive-shell: headless completion callback error:", error);
}
}
this.completeCallbacks = [];
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stopQuietTimer();
this.stopPollTimer();
if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; }
this.unsubscribe();
}
}
function normalizeMonitorSnapshot(raw: string): string {
if (!raw) return "";
const normalizedLineEndings = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
return normalizedLineEndings
.replace(/[\t ]+$/gm, "")
.trimEnd();
}
function summarizeDiff(previous: string, current: string): string {
if (previous === current) return "No change";
if (!previous && current) return `Output changed: now has content (${current.length} chars)`;
if (previous && !current) return "Output changed: now empty";
const prevLines = previous.split("\n");
const nextLines = current.split("\n");
const max = Math.max(prevLines.length, nextLines.length);
for (let i = 0; i < max; i++) {
const before = prevLines[i] ?? "";
const after = nextLines[i] ?? "";
if (before === after) continue;
const left = before.length > 120 ? `${before.slice(0, 117)}...` : before;
const right = after.length > 120 ? `${after.slice(0, 117)}...` : after;
return `Output changed at line ${i + 1}: "${left}" -> "${right}"`;
}
return `Output changed (${previous.length} chars -> ${current.length} chars)`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,270 @@
/**
* 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;
}
// Bracketed paste
if (input.paste) {
result += encodePaste(input.paste);
}
// Named keys with modifier support
if (input.keys) {
for (const key of input.keys) {
result += encodeKeyToken(key);
}
}
return result;
}

View File

@@ -0,0 +1,178 @@
import type { InteractiveShellResult, HandsFreeUpdate, MonitorEventPayload, MonitorSessionState } from "./types.js";
import type { HeadlessCompletionInfo } from "./headless-monitor.js";
import { formatDurationMs } from "./types.js";
const BRIEF_TAIL_LINES = 5;
export function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
const parts = [buildDispatchStatusLine(sessionId, info, duration)];
if (info.completionOutput && info.completionOutput.totalLines > 0) {
parts.push(` ${info.completionOutput.totalLines} lines of output.`);
}
appendTailBlock(parts, info.completionOutput?.lines, BRIEF_TAIL_LINES);
parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
return parts.join("");
}
export function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
const parts = [buildResultStatusLine(sessionId, result)];
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("");
}
export function buildMonitorEventNotification(event: MonitorEventPayload): string {
return [
`Monitor Event (${event.sessionId}) #${event.eventId}`,
`Time: ${event.timestamp}`,
`Strategy: ${event.strategy}`,
`Trigger: ${event.triggerId}`,
`Matched: ${event.matchedText}`,
`${event.strategy === "poll-diff" ? "Diff" : "Line"}: ${event.lineOrDiff}`,
].join("\n");
}
export function buildMonitorLifecycleNotification(state: MonitorSessionState): string {
const reason = state.terminalReason ?? "stopped";
let headline: string;
if (reason === "stream-ended") {
headline = `Monitor ${state.sessionId} stream ended.`;
} else if (reason === "timed-out") {
headline = `Monitor ${state.sessionId} timed out.`;
} else if (reason === "script-failed") {
headline = `Monitor ${state.sessionId} script failed.`;
} else {
headline = `Monitor ${state.sessionId} stopped.`;
}
const details: string[] = [
headline,
`Strategy: ${state.strategy}`,
`Events: ${state.eventCount}`,
state.lastEventAt ? `Last event: #${state.lastEventId} at ${state.lastEventAt}` : "Last event: none",
];
if (state.exitCode !== undefined && state.exitCode !== null) {
details.push(`Exit code: ${state.exitCode}`);
}
if (state.signal !== undefined) {
details.push(`Signal: ${state.signal}`);
}
return details.join("\n");
}
export function buildHandsFreeUpdateMessage(update: HandsFreeUpdate): { content: string; details: HandsFreeUpdate } | null {
if (update.status === "running") return null;
const tail = update.tail.length > 0 ? `\n\n${update.tail.join("\n")}` : "";
let statusLine: string;
switch (update.status) {
case "exited":
statusLine = `Session ${update.sessionId} exited (${formatDurationMs(update.runtime)})`;
break;
case "killed":
statusLine = `Session ${update.sessionId} killed (${formatDurationMs(update.runtime)})`;
break;
case "user-takeover":
statusLine = `Session ${update.sessionId}: user took over (${formatDurationMs(update.runtime)})`;
break;
case "agent-resumed":
statusLine = `Session ${update.sessionId}: agent resumed monitoring (${formatDurationMs(update.runtime)})`;
break;
default:
statusLine = `Session ${update.sessionId} update (${formatDurationMs(update.runtime)})`;
}
return { content: statusLine + tail, details: update };
}
export function summarizeInteractiveResult(command: string, result: InteractiveShellResult, timeout?: number, reason?: string): string {
let summary = buildInteractiveSummary(result, timeout);
if (result.userTookOver) {
summary += "\n\nNote: User took over control during hands-free mode.";
}
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")}`;
}
const warning = buildIdlePromptWarning(command, reason);
if (warning) {
summary += `\n\n${warning}`;
}
return summary;
}
export 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", "agent"] as const;
const bin = binaries.find((candidate) => trimmed === candidate || trimmed.startsWith(`${candidate} `));
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 (!looksLikeIdleCommand(rest)) return null;
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}"\`.`;
}
function buildDispatchStatusLine(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
if (info.timedOut) return `Session ${sessionId} timed out (${duration}).`;
if (info.cancelled) return `Session ${sessionId} was killed (${duration}).`;
if (info.exitCode === 0) return `Session ${sessionId} completed successfully (${duration}).`;
return `Session ${sessionId} exited with code ${info.exitCode} (${duration}).`;
}
function buildResultStatusLine(sessionId: string, result: InteractiveShellResult): string {
if (result.timedOut) return `Session ${sessionId} timed out.`;
if (result.cancelled) return `Session ${sessionId} was killed.`;
if (result.exitCode === 0) return `Session ${sessionId} completed successfully.`;
return `Session ${sessionId} exited with code ${result.exitCode}.`;
}
function buildInteractiveSummary(result: InteractiveShellResult, timeout?: number): string {
if (result.transferred) {
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
return `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
}
if (result.backgrounded) {
return `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
}
if (result.cancelled) return "User killed the interactive session";
if (result.timedOut) return `Session killed after timeout (${timeout ?? "?"}ms)`;
const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
return `Session ended ${status}`;
}
function appendTailBlock(parts: string[], lines: string[] | undefined, tailLines: number): void {
if (!lines || lines.length === 0) return;
let end = lines.length;
while (end > 0 && lines[end - 1].trim() === "") end--;
const tail = lines.slice(Math.max(0, end - tailLines), end);
if (tail.length > 0) {
parts.push(`\n\n${tail.join("\n")}`);
}
}
function looksLikeIdleCommand(rest: string): boolean {
return rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+|\s+[^\s-][^\s]*)?\s*)+$/.test(rest);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
{
"name": "pi-interactive-shell",
"version": "0.13.0",
"description": "Run AI coding agents in pi TUI overlays with interactive, hands-free, and dispatch supervision",
"type": "module",
"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",
"background-widget.ts",
"handoff-utils.ts",
"notification-utils.ts",
"pty-log.ts",
"pty-protocol.ts",
"runtime-coordinator.ts",
"session-query.ts",
"spawn.ts",
"skills/**/*",
"examples/",
"banner.png",
"README.md",
"CHANGELOG.md"
],
"pi": {
"extensions": [
"./index.ts"
],
"skills": [
"./skills"
],
"video": "https://github.com/nicobailon/pi-interactive-shell/raw/refs/heads/main/pi-interactive-shell-extension.mp4"
},
"dependencies": {
"@xterm/addon-serialize": "^0.13.0",
"@xterm/headless": "^5.5.0",
"typebox": "^1.1.24",
"zigpty": "^0.1.6"
},
"scripts": {
"test": "vitest run"
},
"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",
"devDependencies": {
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,59 @@
import { stripVTControlCharacters } from "node:util";
export const MAX_RAW_OUTPUT_SIZE = 1024 * 1024;
export function trimRawOutput(rawOutput: string, lastStreamPosition: number): { rawOutput: string; lastStreamPosition: number } {
if (rawOutput.length <= MAX_RAW_OUTPUT_SIZE) {
return { rawOutput, lastStreamPosition };
}
const keepSize = Math.floor(MAX_RAW_OUTPUT_SIZE / 2);
const trimAmount = rawOutput.length - keepSize;
return {
rawOutput: rawOutput.substring(trimAmount),
lastStreamPosition: Math.max(0, lastStreamPosition - trimAmount),
};
}
export function sliceLogOutput(text: string, options: { offset?: number; limit?: number; stripAnsi?: boolean } = {}): {
slice: string;
totalLines: number;
totalChars: number;
sliceLineCount: number;
} {
let source = text;
if (options.stripAnsi !== false && source) {
source = stripVTControlCharacters(source);
}
if (!source) {
return { slice: "", totalLines: 0, totalChars: 0, sliceLineCount: 0 };
}
const normalized = source.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
const totalLines = lines.length;
const totalChars = source.length;
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) {
const tailCount = Math.max(0, Math.floor(options.limit));
start = Math.max(totalLines - tailCount, 0);
} else {
start = 0;
}
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,
};
}

View File

@@ -0,0 +1,33 @@
// DSR (Device Status Report) - cursor position query: ESC[6n or ESC[?6n
const DSR_PATTERN = /\x1b\[\??6n/g;
/** Result of splitting PTY output around device-status-report cursor queries. */
export interface DsrSplit {
segments: Array<{ text: string; dsrAfter: boolean }>;
hasDsr: boolean;
}
export function splitAroundDsr(input: string): DsrSplit {
const segments: Array<{ text: string; dsrAfter: boolean }> = [];
let lastIndex = 0;
let hasDsr = false;
const regex = new RegExp(DSR_PATTERN.source, "g");
let match: RegExpExecArray | null;
while ((match = regex.exec(input)) !== null) {
hasDsr = true;
if (match.index > lastIndex) {
segments.push({ text: input.slice(lastIndex, match.index), dsrAfter: true });
} else {
segments.push({ text: "", dsrAfter: true });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < input.length) {
segments.push({ text: input.slice(lastIndex), dsrAfter: false });
}
return { segments, hasDsr };
}
export function buildCursorPositionResponse(row = 1, col = 1): string {
return `\x1b[${row};${col}R`;
}

View File

@@ -0,0 +1,614 @@
import { stripVTControlCharacters } from "node:util";
import { spawn, type IPty } from "zigpty";
import type { IBufferCell, Terminal as XtermTerminal } from "@xterm/headless";
import xterm from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
import { sliceLogOutput, trimRawOutput } from "./pty-log.js";
import { splitAroundDsr, buildCursorPositionResponse } from "./pty-protocol.js";
const Terminal = xterm.Terminal;
// 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;
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: 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 {
const trimmed = trimRawOutput(this.rawOutput, this.lastStreamPosition);
this.rawOutput = trimmed.rawOutput;
this.lastStreamPosition = trimmed.lastStreamPosition;
}
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 mergedEnvRaw = env ? { ...process.env, ...env } : { ...process.env };
if (!mergedEnvRaw.TERM) mergedEnvRaw.TERM = "xterm-256color";
const mergedEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(mergedEnvRaw)) {
if (value !== undefined) mergedEnv[key] = value;
}
this.ptyProcess = spawn(shell, shellArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: mergedEnv,
});
this.ptyProcess.onData((data) => {
const chunk = typeof data === "string" ? data : data.toString("utf8");
// 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(chunk);
if (!hasDsr) {
// Fast path: no DSR in data
this.writeQueue.enqueue(async () => {
this.rawOutput += chunk;
this.trimRawOutputIfNeeded();
await new Promise<void>((resolve) => {
this.xterm.write(chunk, () => resolve());
});
this.notifyDataListeners(chunk);
});
} 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);
// Copy array to avoid issues if a listener unsubscribes during iteration
for (const listener of [...this.additionalDataListeners]) {
listener(data);
}
}
private notifyExitListeners(exitCode: number, signal?: number): void {
this.exitHandler?.(exitCode, signal);
// Copy array to avoid issues if a listener unsubscribes during iteration
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;
} {
return sliceLogOutput(this.rawOutput, options);
}
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();
try {
this.ptyProcess.close();
} catch {
// Ignore close errors during teardown.
}
this.xterm.dispose();
}
}

View File

@@ -0,0 +1,446 @@
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
import { PtyTerminalSession } from "./pty-session.js";
import { sessionManager } from "./session-manager.js";
import type { InteractiveShellConfig } from "./config.js";
import {
type InteractiveShellResult,
type DialogChoice,
type OverlayState,
HEADER_LINES,
FOOTER_LINES_COMPACT,
FOOTER_LINES_DIALOG,
formatShortcut,
} from "./types.js";
import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
export class ReattachOverlay implements Component, Focusable {
focused = false;
private tui: TUI;
private theme: Theme;
private done: (result: InteractiveShellResult) => void;
private bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession };
private config: InteractiveShellConfig;
private state: OverlayState = "running";
private dialogSelection: DialogChoice = "transfer";
private exitCountdown = 0;
private countdownInterval: ReturnType<typeof setInterval> | null = null;
private initialExitTimeout: ReturnType<typeof setTimeout> | null = null;
private lastWidth = 0;
private lastHeight = 0;
private finished = false;
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(
tui: TUI,
theme: Theme,
bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession },
config: InteractiveShellConfig,
done: (result: InteractiveShellResult) => void,
private onUnfocus?: () => void,
) {
this.tui = tui;
this.theme = theme;
this.bgSession = bgSession;
this.config = config;
this.done = done;
bgSession.session.setEventHandlers({
onData: () => {
if (!bgSession.session.isScrolledUp()) {
bgSession.session.scrollToBottom();
}
this.debouncedRender();
},
onExit: () => {
if (this.finished) return;
this.state = "exited";
this.exitCountdown = this.config.exitAutoCloseDelay;
this.startExitCountdown();
this.tui.requestRender();
},
});
if (bgSession.session.exited) {
this.state = "exited";
this.exitCountdown = this.config.exitAutoCloseDelay;
this.initialExitTimeout = setTimeout(() => {
this.initialExitTimeout = null;
this.startExitCountdown();
}, 0);
}
const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
const cols = Math.max(20, overlayWidth - 4);
const rows = Math.max(3, overlayHeight - (HEADER_LINES + FOOTER_LINES_COMPACT + 2));
bgSession.session.resize(cols, rows);
}
private get session(): PtyTerminalSession {
return this.bgSession.session;
}
private debouncedRender(): void {
if (this.renderTimeout) {
clearTimeout(this.renderTimeout);
}
this.renderTimeout = setTimeout(() => {
this.renderTimeout = null;
this.tui.requestRender();
}, 16);
}
private startExitCountdown(): void {
this.stopCountdown();
this.countdownInterval = setInterval(() => {
this.exitCountdown--;
if (this.exitCountdown <= 0) {
this.finishAndClose();
} else {
this.tui.requestRender();
}
}, 1000);
}
private stopCountdown(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
return captureCompletionOutput(this.session, this.config);
}
/** Capture output for transfer action (Ctrl+T or dialog) */
private captureTransferOutput(): InteractiveShellResult["transferred"] {
return captureTransferOutput(this.session, this.config);
}
private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
return maybeBuildHandoffPreview(this.session, when, this.config);
}
private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoff"] | undefined {
return maybeWriteHandoffSnapshot(this.session, when, this.config, { command: this.bgSession.command });
}
private finishAndClose(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const handoffPreview = this.maybeBuildHandoffPreview("exit");
const handoff = this.maybeWriteHandoffSnapshot("exit");
const completionOutput = this.captureCompletionOutput();
sessionManager.remove(this.bgSession.id);
this.done({
exitCode: this.session.exitCode,
signal: this.session.signal,
backgrounded: false,
cancelled: false,
completionOutput,
handoffPreview,
handoff,
});
}
private finishWithBackground(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const handoffPreview = this.maybeBuildHandoffPreview("detach");
const handoff = this.maybeWriteHandoffSnapshot("detach");
this.session.setEventHandlers({});
if (this.session.exited) {
sessionManager.scheduleCleanup(this.bgSession.id);
}
this.done({
exitCode: null,
backgrounded: true,
backgroundId: this.bgSession.id,
cancelled: false,
handoffPreview,
handoff,
});
}
private finishWithKill(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const handoffPreview = this.maybeBuildHandoffPreview("kill");
const handoff = this.maybeWriteHandoffSnapshot("kill");
const completionOutput = this.captureCompletionOutput();
sessionManager.remove(this.bgSession.id);
this.done({
exitCode: null,
backgrounded: false,
cancelled: true,
completionOutput,
handoffPreview,
handoff,
});
}
private finishWithTransfer(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const transferred = this.captureTransferOutput();
const handoffPreview = this.maybeBuildHandoffPreview("transfer");
const handoff = this.maybeWriteHandoffSnapshot("transfer");
const completionOutput = this.captureCompletionOutput();
sessionManager.remove(this.bgSession.id);
this.done({
exitCode: this.session.exitCode,
signal: this.session.signal,
backgrounded: false,
cancelled: false,
transferred,
completionOutput,
handoffPreview,
handoff,
});
}
handleInput(data: string): void {
if (this.state === "detach-dialog") {
this.handleDialogInput(data);
return;
}
if (matchesKey(data, this.config.focusShortcut)) {
this.onUnfocus?.();
return;
}
// Ctrl+T: Quick transfer - capture output and close (works in all states including "exited")
if (matchesKey(data, "ctrl+t")) {
this.finishWithTransfer();
return;
}
// Ctrl+B: Quick background - dismiss overlay, keep process running
if (matchesKey(data, "ctrl+b") && !this.session.exited) {
this.finishWithBackground();
return;
}
if (this.state === "exited") {
if (data.length > 0) {
this.finishAndClose();
}
return;
}
if (this.session.exited && this.state === "running") {
this.state = "exited";
this.exitCountdown = this.config.exitAutoCloseDelay;
this.startExitCountdown();
this.tui.requestRender();
return;
}
// Ctrl+Q opens detach dialog
if (matchesKey(data, "ctrl+q")) {
this.state = "detach-dialog";
this.dialogSelection = "transfer";
this.tui.requestRender();
return;
}
if (matchesKey(data, "shift+up")) {
this.session.scrollUp(Math.max(1, this.session.rows - 2));
this.tui.requestRender();
return;
}
if (matchesKey(data, "shift+down")) {
this.session.scrollDown(Math.max(1, this.session.rows - 2));
this.tui.requestRender();
return;
}
this.session.write(data);
}
private handleDialogInput(data: string): void {
if (matchesKey(data, "escape")) {
this.state = "running";
this.tui.requestRender();
return;
}
if (matchesKey(data, "up") || matchesKey(data, "down")) {
const options: DialogChoice[] = ["transfer", "background", "kill", "cancel"];
const currentIdx = options.indexOf(this.dialogSelection);
const direction = matchesKey(data, "up") ? -1 : 1;
const newIdx = (currentIdx + direction + options.length) % options.length;
this.dialogSelection = options[newIdx]!;
this.tui.requestRender();
return;
}
if (matchesKey(data, "enter")) {
switch (this.dialogSelection) {
case "transfer":
this.finishWithTransfer();
break;
case "kill":
this.finishWithKill();
break;
case "background":
this.finishWithBackground();
break;
case "cancel":
this.state = "running";
this.tui.requestRender();
break;
}
}
}
render(width: number): string[] {
width = Math.max(4, width);
const th = this.theme;
const borderColor = this.focused ? "border" : "borderMuted";
const border = (s: string) => th.fg(borderColor, s);
const accent = (s: string) => th.fg("accent", s);
const dim = (s: string) => th.fg("dim", s);
const warning = (s: string) => th.fg("warning", s);
const innerWidth = width - 4;
const pad = (s: string, w: number) => {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, w - vis));
};
const row = (content: string) => border("│ ") + pad(content, innerWidth) + border(" │");
const emptyRow = () => row("");
const lines: string[] = [];
// Sanitize command: collapse newlines and whitespace to single spaces for display
const sanitizedCommand = this.bgSession.command.replace(/\s+/g, " ").trim();
const title = truncateToWidth(sanitizedCommand, innerWidth - 30, "...");
const idLabel = `[${this.bgSession.id}]`;
const pid = `PID: ${this.session.pid}`;
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
lines.push(
row(
accent(title) +
" " +
dim(idLabel) +
" ".repeat(
Math.max(1, innerWidth - visibleWidth(title) - idLabel.length - pid.length - 1),
) +
dim(pid),
),
);
// Sanitize reason: collapse newlines and whitespace to single spaces for display
const sanitizedReason = this.bgSession.reason?.replace(/\s+/g, " ").trim();
const hint = sanitizedReason
? `Reattached • ${sanitizedReason} • Ctrl+B background`
: "Reattached • Ctrl+B background";
lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100);
const footerHeight = this.state === "detach-dialog" ? FOOTER_LINES_DIALOG : FOOTER_LINES_COMPACT;
const chrome = HEADER_LINES + footerHeight + 2;
const termRows = Math.max(0, overlayHeight - chrome);
if (termRows > 0) {
if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) {
this.session.resize(innerWidth, termRows);
this.lastWidth = innerWidth;
this.lastHeight = termRows;
// After resize, ensure we're at the bottom to prevent flash to top
this.session.scrollToBottom();
}
const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
for (const line of viewportLines) {
lines.push(row(truncateToWidth(line, innerWidth, "")));
}
}
if (this.session.isScrolledUp()) {
const hintText = "── ↑ scrolled ──";
const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2));
lines.push(
border("├") +
dim(
" ".repeat(padLen) +
hintText +
" ".repeat(width - 2 - padLen - visibleWidth(hintText)),
) +
border("┤"),
);
} else {
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
}
const footerLines: string[] = [];
const focusHint = `${formatShortcut(this.config.focusShortcut)} ${this.focused ? "unfocus" : "focus shell"}`;
if (this.state === "detach-dialog") {
footerLines.push(row(accent("Session actions:")));
const opts: Array<{ key: DialogChoice; label: string }> = [
{ key: "transfer", label: "Transfer output to agent" },
{ key: "background", label: "Run in background" },
{ key: "kill", label: "Kill process" },
{ key: "cancel", label: "Cancel (return to session)" },
];
for (const opt of opts) {
const sel = this.dialogSelection === opt.key;
footerLines.push(row((sel ? accent("▶ ") : " ") + (sel ? accent(opt.label) : opt.label)));
}
footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel")));
} else if (this.state === "exited") {
const exitMsg =
this.session.exitCode === 0
? th.fg("success", "✓ Exited successfully")
: warning(`✗ Exited with code ${this.session.exitCode}`);
footerLines.push(row(exitMsg));
footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close) • ${focusHint}`)));
} else if (this.focused) {
footerLines.push(row(dim(`Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • Shift+Up/Down scroll • ${focusHint}`)));
} else {
footerLines.push(row(dim(focusHint)));
}
while (footerLines.length < footerHeight) {
footerLines.push(emptyRow());
}
lines.push(...footerLines);
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
return lines;
}
invalidate(): void {
this.lastWidth = 0;
this.lastHeight = 0;
}
dispose(): void {
if (this.initialExitTimeout) {
clearTimeout(this.initialExitTimeout);
this.initialExitTimeout = null;
}
if (this.renderTimeout) {
clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
this.stopCountdown();
this.session.setEventHandlers({});
}
}

View File

@@ -0,0 +1,216 @@
import type { OverlayHandle } from "@mariozechner/pi-tui";
import type { HeadlessDispatchMonitor } from "./headless-monitor.js";
import type { MonitorConfig, MonitorEventPayload, MonitorSessionState, MonitorTerminalReason } from "./types.js";
const MONITOR_HISTORY_LIMIT = 200;
/** Centralizes overlay, monitor, widget, and completion-suppression state for the extension runtime. */
export class InteractiveShellCoordinator {
private overlayOpen = false;
private overlayHandle: OverlayHandle | null = null;
private headlessMonitors = new Map<string, HeadlessDispatchMonitor>();
private monitorEventHistory = new Map<string, MonitorEventPayload[]>();
private monitorEventCounters = new Map<string, number>();
private monitorSessionState = new Map<string, MonitorSessionState>();
private pendingMonitorReason = new Map<string, MonitorTerminalReason>();
private bgWidgetCleanup: (() => void) | null = null;
private agentHandledCompletion = new Set<string>();
isOverlayOpen(): boolean {
return this.overlayOpen;
}
beginOverlay(): boolean {
if (this.overlayOpen) return false;
this.overlayOpen = true;
return true;
}
endOverlay(): void {
this.overlayOpen = false;
this.clearOverlayHandle();
}
focusOverlay(): void {
this.overlayHandle?.focus();
}
unfocusOverlay(): void {
this.overlayHandle?.unfocus();
}
isOverlayFocused(): boolean {
return this.overlayHandle?.isFocused() === true;
}
setOverlayHandle(handle: OverlayHandle): void {
this.overlayHandle = handle;
}
clearOverlayHandle(): void {
this.overlayHandle = null;
}
markAgentHandledCompletion(sessionId: string): void {
this.agentHandledCompletion.add(sessionId);
}
consumeAgentHandledCompletion(sessionId: string): boolean {
const had = this.agentHandledCompletion.has(sessionId);
this.agentHandledCompletion.delete(sessionId);
return had;
}
setMonitor(id: string, monitor: HeadlessDispatchMonitor): void {
this.headlessMonitors.set(id, monitor);
}
getMonitor(id: string): HeadlessDispatchMonitor | undefined {
return this.headlessMonitors.get(id);
}
deleteMonitor(id: string): void {
this.headlessMonitors.delete(id);
}
registerMonitorSession(sessionId: string, monitor: MonitorConfig, startedAt: Date): MonitorSessionState {
const state: MonitorSessionState = {
sessionId,
strategy: monitor.strategy ?? "stream",
triggerIds: monitor.triggers.map((trigger) => trigger.id),
status: "running",
eventCount: 0,
startedAt: startedAt.toISOString(),
};
this.monitorSessionState.set(sessionId, state);
return state;
}
markMonitorStopping(sessionId: string, reason: MonitorTerminalReason = "stopped"): void {
this.pendingMonitorReason.set(sessionId, reason);
}
consumePendingMonitorReason(sessionId: string): MonitorTerminalReason | undefined {
const reason = this.pendingMonitorReason.get(sessionId);
this.pendingMonitorReason.delete(sessionId);
return reason;
}
finalizeMonitorSession(
sessionId: string,
result: { exitCode: number | null; signal?: number },
reason: MonitorTerminalReason,
): MonitorSessionState | undefined {
const current = this.monitorSessionState.get(sessionId);
if (!current) return undefined;
const finalized: MonitorSessionState = {
...current,
status: "stopped",
endedAt: new Date().toISOString(),
terminalReason: reason,
exitCode: result.exitCode,
signal: result.signal,
};
this.monitorSessionState.set(sessionId, finalized);
this.pendingMonitorReason.delete(sessionId);
return finalized;
}
getMonitorSessionState(sessionId: string): MonitorSessionState | undefined {
return this.monitorSessionState.get(sessionId);
}
recordMonitorEvent(event: Omit<MonitorEventPayload, "eventId" | "timestamp">): MonitorEventPayload {
const nextId = (this.monitorEventCounters.get(event.sessionId) ?? 0) + 1;
this.monitorEventCounters.set(event.sessionId, nextId);
const recorded: MonitorEventPayload = {
...event,
eventId: nextId,
timestamp: new Date().toISOString(),
};
const existing = this.monitorEventHistory.get(event.sessionId) ?? [];
existing.push(recorded);
if (existing.length > MONITOR_HISTORY_LIMIT) {
existing.splice(0, existing.length - MONITOR_HISTORY_LIMIT);
}
this.monitorEventHistory.set(event.sessionId, existing);
const currentState = this.monitorSessionState.get(event.sessionId);
if (currentState) {
this.monitorSessionState.set(event.sessionId, {
...currentState,
eventCount: nextId,
lastEventId: recorded.eventId,
lastEventAt: recorded.timestamp,
lastTriggerId: recorded.triggerId,
});
}
return recorded;
}
getMonitorEvents(sessionId: string, options?: { limit?: number; offset?: number; sinceEventId?: number; triggerId?: string }): {
events: MonitorEventPayload[];
total: number;
limit: number;
offset: number;
sinceEventId?: number;
triggerId?: string;
} {
let events = this.monitorEventHistory.get(sessionId) ?? [];
const sinceEventId = options?.sinceEventId !== undefined ? Math.max(0, Math.trunc(options.sinceEventId)) : undefined;
if (sinceEventId !== undefined) {
events = events.filter((event) => event.eventId > sinceEventId);
}
const triggerId = options?.triggerId?.trim();
if (triggerId) {
events = events.filter((event) => event.triggerId === triggerId);
}
const total = events.length;
const limit = Math.max(1, Math.trunc(options?.limit ?? 20));
const offset = Math.max(0, Math.trunc(options?.offset ?? 0));
const end = Math.max(0, total - offset);
const start = Math.max(0, end - limit);
return {
events: events.slice(start, end),
total,
limit,
offset,
sinceEventId,
triggerId,
};
}
clearMonitorEvents(sessionId: string): void {
this.monitorEventHistory.delete(sessionId);
this.monitorEventCounters.delete(sessionId);
this.monitorSessionState.delete(sessionId);
this.pendingMonitorReason.delete(sessionId);
}
disposeMonitor(id: string): void {
const monitor = this.headlessMonitors.get(id);
if (!monitor) return;
monitor.dispose();
this.headlessMonitors.delete(id);
}
disposeAllMonitors(): void {
for (const monitor of this.headlessMonitors.values()) {
monitor.dispose();
}
this.headlessMonitors.clear();
}
replaceBackgroundWidgetCleanup(cleanup: (() => void) | null): void {
this.bgWidgetCleanup?.();
this.bgWidgetCleanup = cleanup;
}
clearBackgroundWidget(): void {
this.bgWidgetCleanup?.();
this.bgWidgetCleanup = null;
}
}

View File

@@ -0,0 +1,355 @@
import { PtyTerminalSession } from "./pty-session.js";
export interface BackgroundSession {
id: string;
name: string;
command: string;
reason?: string;
session: PtyTerminalSession;
startedAt: Date;
}
export type ActiveSessionStatus = "running" | "monitoring" | "user-takeover" | "exited" | "killed" | "backgrounded";
export interface ActiveSessionResult {
exitCode: number | null;
signal?: number;
backgrounded?: boolean;
backgroundId?: string;
cancelled?: boolean;
timedOut?: boolean;
}
export interface OutputResult {
output: string;
truncated: boolean;
totalBytes: number;
// For incremental/offset modes
totalLines?: number;
hasMore?: boolean;
// Rate limiting
rateLimited?: boolean;
waitSeconds?: number;
}
export interface OutputOptions {
skipRateLimit?: boolean;
lines?: number; // Override default 20 lines
maxChars?: number; // Override default 5KB
offset?: number; // Line offset for pagination (0-indexed)
drain?: boolean; // If true, return only NEW output since last query (raw stream)
incremental?: boolean; // If true, return next N lines not yet seen (server tracks position)
}
export interface ActiveSession {
id: string;
command: string;
reason?: string;
write: (data: string) => void;
kill: () => void;
background: () => void;
getOutput: (options?: OutputOptions | boolean) => OutputResult;
getStatus: () => ActiveSessionStatus;
getRuntime: () => number;
getResult: () => ActiveSessionResult | undefined;
setUpdateInterval?: (intervalMs: number) => void;
setQuietThreshold?: (thresholdMs: number) => void;
onComplete: (callback: () => void) => void;
}
// Human-readable session slug generation
const SLUG_ADJECTIVES = [
"amber", "brisk", "calm", "clear", "cool", "crisp", "dawn", "ember",
"fast", "fresh", "gentle", "keen", "kind", "lucky", "mellow", "mild",
"neat", "nimble", "nova", "quick", "quiet", "rapid", "sharp", "swift",
"tender", "tidy", "vivid", "warm", "wild", "young",
];
const SLUG_NOUNS = [
"atlas", "bloom", "breeze", "cedar", "cloud", "comet", "coral", "cove",
"crest", "delta", "dune", "ember", "falcon", "fjord", "glade", "haven",
"kelp", "lagoon", "meadow", "mist", "nexus", "orbit", "pine", "reef",
"ridge", "river", "sage", "shell", "shore", "summit", "trail", "zephyr",
];
function randomChoice<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
// Track used IDs to avoid collisions
const usedIds = new Set<string>();
export function generateSessionId(name?: string): string {
// If a custom name is provided, use simple counter approach
if (name) {
let counter = 1;
let id = name;
while (usedIds.has(id)) {
counter++;
id = `${name}-${counter}`;
}
usedIds.add(id);
return id;
}
// Generate human-readable slug
for (let attempt = 0; attempt < 20; attempt++) {
const adj = randomChoice(SLUG_ADJECTIVES);
const noun = randomChoice(SLUG_NOUNS);
const base = `${adj}-${noun}`;
if (!usedIds.has(base)) {
usedIds.add(base);
return base;
}
// Try with suffix
for (let i = 2; i <= 9; i++) {
const candidate = `${base}-${i}`;
if (!usedIds.has(candidate)) {
usedIds.add(candidate);
return candidate;
}
}
}
// Fallback: timestamp-based
const fallback = `shell-${Date.now().toString(36)}`;
usedIds.add(fallback);
return fallback;
}
export function releaseSessionId(id: string): void {
usedIds.delete(id);
}
// Derive a friendly display name from command (e.g., "pi Fix all bugs" -> "pi Fix all bugs")
function deriveSessionName(command: string): string {
const trimmed = command.trim();
if (trimmed.length <= 60) return trimmed;
// Truncate with ellipsis
return trimmed.slice(0, 57) + "...";
}
export class ShellSessionManager {
private sessions = new Map<string, BackgroundSession>();
private exitWatchers = new Map<string, NodeJS.Timeout>();
private cleanupTimers = new Map<string, NodeJS.Timeout>();
private activeSessions = new Map<string, ActiveSession>();
private changeListeners = new Set<() => void>();
onChange(listener: () => void): () => void {
this.changeListeners.add(listener);
return () => { this.changeListeners.delete(listener); };
}
private notifyChange(): void {
for (const listener of this.changeListeners) {
try {
listener();
} catch (error) {
console.error("interactive-shell: change listener error:", error);
}
}
}
registerActive(session: ActiveSession): void {
this.activeSessions.set(session.id, session);
}
unregisterActive(id: string, releaseId = false): void {
this.activeSessions.delete(id);
// Only release the ID if explicitly requested (when session fully terminates)
// This prevents ID reuse while session is still running after takeover
if (releaseId) {
releaseSessionId(id);
}
}
getActive(id: string): ActiveSession | undefined {
return this.activeSessions.get(id);
}
writeToActive(id: string, data: string): boolean {
const session = this.activeSessions.get(id);
if (!session) return false;
session.write(data);
return true;
}
setActiveUpdateInterval(id: string, intervalMs: number): boolean {
const session = this.activeSessions.get(id);
if (!session?.setUpdateInterval) return false;
session.setUpdateInterval(intervalMs);
return true;
}
setActiveQuietThreshold(id: string, thresholdMs: number): boolean {
const session = this.activeSessions.get(id);
if (!session?.setQuietThreshold) return false;
session.setQuietThreshold(thresholdMs);
return true;
}
add(command: string, session: PtyTerminalSession, name?: string, reason?: string, options?: { id?: string; noAutoCleanup?: boolean; startedAt?: Date }): string {
const id = options?.id ?? generateSessionId(name);
if (options?.id) usedIds.add(id);
const entry: BackgroundSession = {
id,
name: name || deriveSessionName(command),
command,
reason,
session,
startedAt: options?.startedAt ?? new Date(),
};
this.storeBackgroundEntry(entry, options?.noAutoCleanup === true);
return id;
}
restore(entry: BackgroundSession, options?: { noAutoCleanup?: boolean }): void {
usedIds.add(entry.id);
this.storeBackgroundEntry(entry, options?.noAutoCleanup === true);
}
private storeBackgroundEntry(entry: BackgroundSession, noAutoCleanup: boolean): void {
this.sessions.set(entry.id, entry);
entry.session.setEventHandlers({});
if (!noAutoCleanup) {
const checkExit = setInterval(() => {
if (entry.session.exited) {
clearInterval(checkExit);
this.exitWatchers.delete(entry.id);
this.notifyChange();
const cleanupTimer = setTimeout(() => {
this.cleanupTimers.delete(entry.id);
this.remove(entry.id);
}, 30000);
this.cleanupTimers.set(entry.id, cleanupTimer);
}
}, 1000);
this.exitWatchers.set(entry.id, checkExit);
}
this.notifyChange();
}
take(id: string): BackgroundSession | undefined {
const watcher = this.exitWatchers.get(id);
if (watcher) {
clearInterval(watcher);
this.exitWatchers.delete(id);
}
const cleanupTimer = this.cleanupTimers.get(id);
if (cleanupTimer) {
clearTimeout(cleanupTimer);
this.cleanupTimers.delete(id);
}
const session = this.sessions.get(id);
if (session) {
this.sessions.delete(id);
this.notifyChange();
return session;
}
return undefined;
}
get(id: string): BackgroundSession | undefined {
// Suspend all auto-cleanup while session is being actively used
const watcher = this.exitWatchers.get(id);
if (watcher) {
clearInterval(watcher);
this.exitWatchers.delete(id);
}
const cleanupTimer = this.cleanupTimers.get(id);
if (cleanupTimer) {
clearTimeout(cleanupTimer);
this.cleanupTimers.delete(id);
}
return this.sessions.get(id);
}
restartAutoCleanup(id: string): void {
if (this.exitWatchers.has(id)) return;
const entry = this.sessions.get(id);
if (!entry) return;
if (entry.session.exited) {
this.scheduleCleanup(id);
return;
}
const checkExit = setInterval(() => {
if (entry.session.exited) {
clearInterval(checkExit);
this.exitWatchers.delete(id);
this.notifyChange();
this.scheduleCleanup(id);
}
}, 1000);
this.exitWatchers.set(id, checkExit);
}
scheduleCleanup(id: string, delayMs = 30000): void {
if (this.cleanupTimers.has(id)) return;
const timer = setTimeout(() => {
this.cleanupTimers.delete(id);
this.remove(id);
}, delayMs);
this.cleanupTimers.set(id, timer);
}
remove(id: string): void {
const watcher = this.exitWatchers.get(id);
if (watcher) {
clearInterval(watcher);
this.exitWatchers.delete(id);
}
const cleanupTimer = this.cleanupTimers.get(id);
if (cleanupTimer) {
clearTimeout(cleanupTimer);
this.cleanupTimers.delete(id);
}
const session = this.sessions.get(id);
if (session) {
session.session.dispose();
this.sessions.delete(id);
releaseSessionId(id);
this.notifyChange();
}
}
list(): BackgroundSession[] {
return Array.from(this.sessions.values());
}
killAll(): void {
// Kill all background sessions
// Collect IDs first to avoid modifying map during iteration
const bgIds = Array.from(this.sessions.keys());
for (const id of bgIds) {
this.remove(id);
}
// Kill all active hands-free sessions
// Collect entries first since kill() may trigger unregisterActive()
const activeEntries = Array.from(this.activeSessions.entries());
for (const [id, session] of activeEntries) {
try {
session.kill();
// Only release ID if kill succeeded - let natural cleanup handle failures
// The session's exit handler will call unregisterActive() which releases the ID
} catch (error) {
console.error(`interactive-shell: failed to kill active session ${id} during shutdown`, error);
// Keep the slug reservation when kill fails so a potentially still-running
// session cannot collide with a newly generated ID.
}
}
// Don't clear immediately - let unregisterActive() handle cleanup as sessions exit
// This prevents ID reuse while processes are still terminating
}
}
export const sessionManager = new ShellSessionManager();

View File

@@ -0,0 +1,170 @@
import type { InteractiveShellConfig } from "./config.js";
import type { OutputOptions, OutputResult } from "./session-manager.js";
import type { InteractiveShellResult } from "./types.js";
import type { PtyTerminalSession } from "./pty-session.js";
/** Mutable query bookkeeping kept per active session. */
export interface SessionQueryState {
lastQueryTime: number;
incrementalReadPosition: number;
}
export const DEFAULT_STATUS_OUTPUT = 5 * 1024;
export const DEFAULT_STATUS_LINES = 20;
export const MAX_STATUS_OUTPUT = 50 * 1024;
export const MAX_STATUS_LINES = 200;
export function createSessionQueryState(): SessionQueryState {
return {
lastQueryTime: 0,
incrementalReadPosition: 0,
};
}
export function getSessionOutput(
session: PtyTerminalSession,
config: InteractiveShellConfig,
state: SessionQueryState,
options: OutputOptions | boolean = false,
completionOutput?: InteractiveShellResult["completionOutput"],
): OutputResult {
if (completionOutput) {
return buildCompletionOutputResult(completionOutput);
}
const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
const requestedLines = clampPositive(opts.lines ?? DEFAULT_STATUS_LINES, MAX_STATUS_LINES);
const requestedMaxChars = clampPositive(opts.maxChars ?? DEFAULT_STATUS_OUTPUT, MAX_STATUS_OUTPUT);
const rateLimited = maybeRateLimitQuery(config, state, opts.skipRateLimit ?? false);
if (rateLimited) return rateLimited;
if (opts.incremental) {
return getIncrementalOutput(session, state, requestedLines, requestedMaxChars);
}
if (opts.drain) {
return buildTruncatedOutput(session.getRawStream({ sinceLast: true, stripAnsi: true }), requestedMaxChars, true);
}
if (opts.offset !== undefined) {
return getOffsetOutput(session, opts.offset, requestedLines, requestedMaxChars);
}
const tailResult = session.getTailLines({
lines: requestedLines,
ansi: false,
maxChars: requestedMaxChars,
});
const output = tailResult.lines.join("\n");
return {
output,
truncated: tailResult.lines.length < tailResult.totalLinesInBuffer || tailResult.truncatedByChars,
totalBytes: output.length,
totalLines: tailResult.totalLinesInBuffer,
};
}
function maybeRateLimitQuery(
config: InteractiveShellConfig,
state: SessionQueryState,
skipRateLimit: boolean,
): OutputResult | null {
if (skipRateLimit) return null;
const now = Date.now();
const minIntervalMs = config.minQueryIntervalSeconds * 1000;
const elapsed = now - state.lastQueryTime;
if (state.lastQueryTime > 0 && elapsed < minIntervalMs) {
return {
output: "",
truncated: false,
totalBytes: 0,
rateLimited: true,
waitSeconds: Math.ceil((minIntervalMs - elapsed) / 1000),
};
}
state.lastQueryTime = now;
return null;
}
function getIncrementalOutput(
session: PtyTerminalSession,
state: SessionQueryState,
requestedLines: number,
requestedMaxChars: number,
): OutputResult {
const result = session.getLogSlice({
offset: state.incrementalReadPosition,
limit: requestedLines,
stripAnsi: true,
});
const output = truncateForMaxChars(result.slice, requestedMaxChars);
state.incrementalReadPosition += result.sliceLineCount;
return {
output: output.value,
truncated: output.truncated,
totalBytes: output.value.length,
totalLines: result.totalLines,
hasMore: state.incrementalReadPosition < result.totalLines,
};
}
function getOffsetOutput(
session: PtyTerminalSession,
offset: number,
requestedLines: number,
requestedMaxChars: number,
): OutputResult {
const result = session.getLogSlice({
offset,
limit: requestedLines,
stripAnsi: true,
});
const output = truncateForMaxChars(result.slice, requestedMaxChars);
const hasMore = (offset + result.sliceLineCount) < result.totalLines;
return {
output: output.value,
truncated: output.truncated || hasMore,
totalBytes: output.value.length,
totalLines: result.totalLines,
hasMore,
};
}
function buildCompletionOutputResult(completionOutput: NonNullable<InteractiveShellResult["completionOutput"]>): OutputResult {
const output = completionOutput.lines.join("\n");
return {
output,
truncated: completionOutput.truncated,
totalBytes: output.length,
totalLines: completionOutput.totalLines,
};
}
function buildTruncatedOutput(output: string, requestedMaxChars: number, sliceFromEnd = false): OutputResult {
const truncated = output.length > requestedMaxChars;
let value = output;
if (truncated) {
value = sliceFromEnd
? output.slice(-requestedMaxChars)
: output.slice(0, requestedMaxChars);
}
return {
output: value,
truncated,
totalBytes: value.length,
};
}
function truncateForMaxChars(output: string, requestedMaxChars: number): { value: string; truncated: boolean } {
if (output.length <= requestedMaxChars) {
return { value: output, truncated: false };
}
return {
value: output.slice(0, requestedMaxChars),
truncated: true,
};
}
function clampPositive(value: number, max: number): number {
return Math.max(1, Math.min(max, value));
}

View File

@@ -0,0 +1,631 @@
---
name: pi-interactive-shell
description: Cheat sheet + workflow for launching interactive coding-agent CLIs (Claude Code, Gemini CLI, Codex CLI, Cursor CLI, and pi itself) via the interactive_shell overlay, headless dispatch, or monitor mode. Use for TUI agents and long-running processes that need supervision, fire-and-forget delegation, or event-driven background monitoring. Regular bash commands should use the bash tool instead.
---
# Interactive Shell (Skill)
Last verified: 2026-04-11
## 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).
**Monitor mode** (`mode: "monitor"`) runs headless and event-driven. It wakes the agent on structured monitor trigger events (stream or poll-diff), so there is no polling loop.
**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.
## Structured Spawn and `/spawn`
For Pi, Codex, Claude, and Cursor, prefer structured `spawn` params when you want the extension's shared resolver, config defaults, native startup prompt forms, or Pi-only fork/worktree behavior:
```typescript
interactive_shell({ spawn: { agent: "pi" }, mode: "interactive" })
interactive_shell({ spawn: { agent: "codex" }, mode: "dispatch" })
interactive_shell({ spawn: { agent: "cursor", prompt: "Review the diffs" }, mode: "dispatch" })
interactive_shell({ spawn: { agent: "claude", prompt: "Review the diffs" }, mode: "dispatch" })
interactive_shell({ spawn: { agent: "claude", worktree: true }, mode: "hands-free" })
interactive_shell({ spawn: { mode: "fork" }, mode: "interactive" }) // Pi-only
```
Structured `spawn` uses the same resolver and defaults as the user-facing `/spawn` command. Raw `command` is still the right choice for arbitrary CLIs and custom launch strings. Cursor structured spawn defaults to `--model composer-2-fast`, which explicitly selects Composer 2 Fast.
For Codex image or design work, Codex can invoke `gpt-image-2` directly from the prompt. Natural language is usually enough, and `$imagegen` forces the image-generation tool when you need it. Attach references with `-i` for edits and iterations. See the bundled `codex-cli` skill for concrete examples.
For users in chat, `/spawn` now supports the configured default agent plus explicit overrides like `/spawn codex`, `/spawn cursor`, `/spawn claude`, `/spawn pi`, `/spawn fork`, and `/spawn pi fork`. Add `--worktree` to run in a separate git worktree.
Quoted prompt text plus `--hands-free` or `--dispatch` turns `/spawn` into a monitored delegated run instead of a plain interactive overlay:
```bash
/spawn cursor "review the diffs" --dispatch
/spawn claude "review the diffs" --dispatch
/spawn codex "fix the failing tests" --hands-free
/spawn pi fork "continue from here" --dispatch
```
## Foreground Subagent Modes
### Interactive (default)
User has full control, types directly into the agent.
```typescript
interactive_shell({ command: 'pi' })
```
### Interactive with Initial Prompt
Agent starts working immediately, user supervises.
```typescript
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`.
```typescript
// 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.
For fire-and-forget delegated runs (including QA-style delegated checks), prefer dispatch as the default mode.
#### Background Dispatch (Headless)
No overlay opens. Multiple headless dispatches can run concurrently:
```typescript
interactive_shell({
command: 'pi "Fix lint errors"',
mode: "dispatch",
background: true
})
// → No overlay. User can /attach to watch. Agent notified on completion.
```
### Monitor (Event-Driven, Headless)
Run a background process and wake the agent on structured monitor triggers.
```typescript
interactive_shell({
command: 'npm test --watch',
mode: "monitor",
monitor: {
strategy: "stream",
triggers: [
{ id: "failed", literal: "FAIL" },
{ id: "error", regex: "/error|warn/i" }
],
throttle: { dedupeExactLine: true }
}
})
interactive_shell({
command: 'curl -sf http://localhost:3000/health',
mode: "monitor",
monitor: {
strategy: "poll-diff",
triggers: [{ id: "changed", regex: "/./" }],
poll: { intervalMs: 5000 }
}
})
```
Use monitor mode for log watchers and long-running checks where polling would be noisy or expensive.
### Hands-Free (Foreground Subagent) - NON-BLOCKING
Agent works autonomously, **returns immediately** with sessionId. You query for status/output and kill when done.
```typescript
// 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
```typescript
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. If the user types to take over a monitored hands-free or dispatch session, they can press `Ctrl+G` to return control to the agent.
### Querying Status
```typescript
interactive_shell({ sessionId: "calm-reef" })
```
Returns:
- `status`: "running" | "monitoring" | "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
```typescript
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:
```typescript
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 ~8s of quiet (after the startup grace period)
// 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:
```typescript
interactive_shell({
spawn: { agent: "cursor" },
mode: "hands-free",
reason: "Interactive refactoring"
})
// Send follow-up prompts
interactive_shell({ sessionId: "calm-reef", input: "Now fix the tests", submit: true })
// Kill when done
interactive_shell({ sessionId: "calm-reef", kill: true })
```
### Sending Input
```typescript
interactive_shell({ sessionId: "calm-reef", input: "/help", submit: true })
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 + keys
```
### Query 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) and `outputMaxChars` (max: 50KB)
```typescript
// 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:
```typescript
// 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:
```typescript
// 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
```typescript
// Send text and submit it
interactive_shell({ sessionId: "shell-1", input: "/help", submit: true })
// 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-`):
```typescript
// 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:
```typescript
inputHex: ["0x1b", "0x5b", "0x41"] // ESC[A (up arrow)
```
### Bracketed Paste
Paste multiline text without triggering autocompletion/execution:
```typescript
inputPaste: "function foo() {\n return 42;\n}"
```
### Model Selection Example
```typescript
// 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
```typescript
interactive_shell({ sessionId: "shell-1", input: "/compact", submit: true })
```
For editor-based TUIs like pi, raw `input` only types text. It does not submit the prompt. Prefer `submit: true` or `inputKeys: ["enter"]` instead of relying on `\n`.
### Changing Update Settings
Adjust timing during a session:
```typescript
// 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`:
```typescript
// 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`:
```typescript
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 agent
```
## Safe TUI Capture
**Never run TUI agents via bash** - they hang even with `--help`. Use `interactive_shell` with `timeout` instead:
```typescript
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
```typescript
// 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" }) // specific
// Start an event-driven monitor session (headless)
interactive_shell({
command: 'npm test --watch',
mode: "monitor",
monitor: { strategy: "stream", triggers: [{ id: "failed", literal: "FAIL" }] }
})
```
## Local Testing Hygiene
When using `interactive_shell` for one-off local testing, do **not** leave sessions running in the background unless the user explicitly wants them kept alive. A stack of background sessions in the widget usually means the agent used backgrounding as an escape hatch and never cleaned up.
Best practice:
- Prefer `kill: true` or normal process exit for finite test runs.
- Only background a session if you expect to come back to it soon or the user asked to keep it.
- Before ending the task, sweep background sessions created for testing.
- Keep background sessions only for intentional long-lived work like dev servers, watchers, or manual validation the user is actively using.
Typical cleanup flow:
```typescript
// Inspect what is still running
interactive_shell({ listBackground: true })
// Dismiss a specific leftover test session
interactive_shell({ dismissBackground: "keen-cove" })
// Or, if the background sessions were just temporary test artifacts from this task,
// dismiss all of them in one sweep
interactive_shell({ dismissBackground: true })
```
To kill all backgrounded interactive shell sessions and their processes in one sweep, use `interactive_shell({ dismissBackground: true })`.
Decision rule:
- **One-off test / repro / validation run** → kill or dismiss it when done.
- **Dev server / watch mode / ongoing manual check** → background only if the user wants it preserved.
If the user backgrounds a session manually with `Ctrl+B`, the agent should still clean it up later unless the user clearly wants it kept.
## Quick Reference
**Dispatch subagent (fire-and-forget, default to pi):**
```typescript
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):**
```typescript
interactive_shell({
command: 'pi "Fix lint errors"',
mode: "dispatch",
background: true,
reason: "Fixing lint"
})
```
**Monitor watcher (event-driven, no polling):**
```typescript
interactive_shell({
command: 'npm run dev',
mode: "monitor",
monitor: {
strategy: "stream",
triggers: [{ id: "warn", regex: "/error|warn/i" }],
persistence: { stopAfterFirstEvent: false }
},
reason: "Wake me on server warnings"
})
```
**Start foreground subagent (hands-free, default to pi):**
```typescript
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:**
```typescript
// Text with enter
interactive_shell({ sessionId: "calm-reef", input: "/compact", submit: true })
// 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:**
```typescript
interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 60000 } })
```
**Foreground subagent (user requested different agent):**
```typescript
interactive_shell({
command: 'claude "Review this code for security issues"',
mode: "hands-free",
reason: "Security review with Claude"
})
```
**Background subagent:**
```typescript
subagent({ agent: "scout", task: "Find all TODO comments" })
```

View File

@@ -0,0 +1,313 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { basename, dirname, join, relative, resolve } from "node:path";
import type { InteractiveShellConfig, SpawnAgent } from "./config.js";
export type SpawnMode = "fresh" | "fork";
export type SpawnMonitorMode = "hands-free" | "dispatch";
export interface SpawnRequest {
agent?: SpawnAgent;
mode?: SpawnMode;
worktree?: boolean;
prompt?: string;
}
export interface ParsedSpawnArgs {
request: SpawnRequest;
monitorMode?: SpawnMonitorMode;
}
export interface ResolvedSpawn {
agent: SpawnAgent;
mode: SpawnMode;
command: string;
cwd: string;
reason: string;
worktreePath?: string;
}
export function parseSpawnArgs(args: string):
| { ok: true; parsed: ParsedSpawnArgs }
| { ok: false; error: string } {
const tokenized = tokenizeSpawnArgs(args);
if (!tokenized.ok) {
return tokenized;
}
let agent: SpawnAgent | undefined;
let mode: SpawnMode | undefined;
let monitorMode: SpawnMonitorMode | undefined;
let worktree = false;
const promptTokens: string[] = [];
for (const token of tokenized.tokens) {
if (!token.quoted && token.value === "--worktree") {
if (worktree) {
return { ok: false, error: "Duplicate flag: --worktree" };
}
worktree = true;
continue;
}
if (!token.quoted && (token.value === "--hands-free" || token.value === "--dispatch")) {
const nextMonitorMode = token.value === "--hands-free" ? "hands-free" : "dispatch";
if (monitorMode) {
return monitorMode === nextMonitorMode
? { ok: false, error: `Duplicate flag: ${token.value}` }
: { ok: false, error: "Cannot combine --hands-free and --dispatch." };
}
monitorMode = nextMonitorMode;
continue;
}
if (!token.quoted && (token.value === "pi" || token.value === "codex" || token.value === "claude" || token.value === "cursor")) {
if (agent) {
return { ok: false, error: `Duplicate spawn agent: ${token.value}` };
}
agent = token.value;
continue;
}
if (!token.quoted && (token.value === "fresh" || token.value === "fork")) {
if (mode) {
return { ok: false, error: `Duplicate spawn mode: ${token.value}` };
}
mode = token.value;
continue;
}
if (!token.quoted && token.value.startsWith("--")) {
return { ok: false, error: `Unknown /spawn argument: ${token.value}` };
}
if (!token.quoted) {
return { ok: false, error: `Unknown /spawn argument: ${token.value}` };
}
promptTokens.push(token.value);
}
if (promptTokens.length > 1) {
return {
ok: false,
error: "Prompt text must be quoted as a single argument, for example /spawn claude \"review the diffs\" --dispatch.",
};
}
const prompt = promptTokens[0];
if (prompt !== undefined && !monitorMode) {
return {
ok: false,
error: "Prompt-bearing /spawn requires --hands-free or --dispatch.",
};
}
if (monitorMode && prompt === undefined) {
return {
ok: false,
error: "Monitored /spawn requires a quoted prompt, for example /spawn claude \"review the diffs\" --dispatch.",
};
}
return {
ok: true,
parsed: {
request: { agent, mode, worktree: worktree || undefined, prompt },
monitorMode,
},
};
}
export function resolveSpawn(
config: InteractiveShellConfig,
cwd: string,
request: SpawnRequest | undefined,
getSessionFile: () => string | undefined,
):
| { ok: true; spawn: ResolvedSpawn }
| { ok: false; error: string } {
const agent = request?.agent ?? config.spawn.defaultAgent;
const mode = request?.mode ?? "fresh";
const worktree = request?.worktree ?? config.spawn.worktree;
const prompt = request?.prompt?.trim();
if (request?.prompt !== undefined && !prompt) {
return { ok: false, error: "Spawn prompt cannot be empty." };
}
if (mode === "fork" && agent !== "pi") {
return { ok: false, error: `Cannot fork ${agent}. Fork is only supported for pi sessions.` };
}
let sourceSessionFile: string | undefined;
if (mode === "fork") {
sourceSessionFile = getSessionFile();
if (!sourceSessionFile) {
return { ok: false, error: "Cannot fork the current session because it is not persisted (likely --no-session mode)." };
}
}
let effectiveCwd = cwd;
let worktreePath: string | undefined;
if (worktree) {
const resolvedWorktree = createSpawnWorktree(config, cwd, agent);
if (!resolvedWorktree.ok) {
return resolvedWorktree;
}
effectiveCwd = resolvedWorktree.cwd;
worktreePath = resolvedWorktree.path;
}
const executable = config.spawn.commands[agent];
const args = [...config.spawn.defaultArgs[agent]];
let reason = `spawn ${agent} (${mode === "fork" ? "fork current session" : "fresh session"})`;
if (sourceSessionFile) {
args.push("--fork", sourceSessionFile);
}
if (prompt) {
args.push(prompt);
}
if (worktreePath) {
reason += ` • worktree: ${worktreePath}`;
}
return {
ok: true,
spawn: {
agent,
mode,
command: buildShellCommand(executable, args),
cwd: effectiveCwd,
reason,
worktreePath,
},
};
}
function createSpawnWorktree(
config: InteractiveShellConfig,
cwd: string,
agent: SpawnAgent,
):
| { ok: true; cwd: string; path: string }
| { ok: false; error: string } {
const workingDir = resolve(cwd);
const repoRoot = runGit(["-C", workingDir, "rev-parse", "--show-toplevel"], workingDir);
if (!repoRoot.ok) {
return { ok: false, error: "Cannot create a worktree here because the current directory is not inside a git repository." };
}
const baseDir = config.spawn.worktreeBaseDir
? resolve(repoRoot.stdout, config.spawn.worktreeBaseDir)
: join(dirname(repoRoot.stdout), `${basename(repoRoot.stdout)}-worktrees`);
mkdirSync(baseDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[-:.]/g, "").replace("T", "-").replace("Z", "");
const suffix = Math.random().toString(36).slice(2, 7);
const worktreePath = join(baseDir, `${basename(repoRoot.stdout)}-${agent}-${timestamp}-${suffix}`);
const addWorktree = runGit(["-C", repoRoot.stdout, "worktree", "add", "--detach", worktreePath, "HEAD"], repoRoot.stdout);
if (!addWorktree.ok) {
return { ok: false, error: addWorktree.error };
}
const relativeCwd = relative(repoRoot.stdout, workingDir);
if (relativeCwd.length === 0 || relativeCwd.startsWith("..")) {
return { ok: true, cwd: worktreePath, path: worktreePath };
}
const nestedCwd = join(worktreePath, relativeCwd);
return {
ok: true,
cwd: existsSync(nestedCwd) ? nestedCwd : worktreePath,
path: worktreePath,
};
}
function runGit(args: string[], cwd: string):
| { ok: true; stdout: string }
| { ok: false; error: string } {
try {
return {
ok: true,
stdout: execFileSync("git", args, {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
}).trim(),
};
} catch (error) {
const stderr = error instanceof Error && "stderr" in error && typeof error.stderr === "string"
? error.stderr.trim()
: "";
const message = error instanceof Error ? error.message : String(error);
return { ok: false, error: stderr ? `${message}\n${stderr}` : message };
}
}
function buildShellCommand(executable: string, args: string[]): string {
return [shellQuoteIfNeeded(executable), ...args.map(shellQuoteIfNeeded)].join(" ");
}
function shellQuoteIfNeeded(value: string): string {
return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : shellQuote(value);
}
function shellQuote(value: string): string {
if (process.platform === "win32") {
return `"${value.replace(/"/g, '""')}"`;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
type ParsedToken = { value: string; quoted: boolean };
function tokenizeSpawnArgs(args: string):
| { ok: true; tokens: ParsedToken[] }
| { ok: false; error: string } {
const tokens: ParsedToken[] = [];
let current = "";
let currentQuoted = false;
let quote: '"' | "'" | null = null;
for (let i = 0; i < args.length; i++) {
const char = args[i];
if (!char) continue;
if (quote) {
if (char === quote) {
quote = null;
currentQuoted = true;
continue;
}
if (char === "\\" && i + 1 < args.length) {
current += args[++i] ?? "";
continue;
}
current += char;
continue;
}
if (/\s/.test(char)) {
if (current.length > 0 || currentQuoted) {
tokens.push({ value: current, quoted: currentQuoted });
current = "";
currentQuoted = false;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
currentQuoted = true;
continue;
}
if (char === "\\" && i + 1 < args.length) {
current += args[++i] ?? "";
continue;
}
current += char;
}
if (quote) {
return { ok: false, error: "Unterminated quote in /spawn arguments." };
}
if (current.length > 0 || currentQuoted) {
tokens.push({ value: current, quoted: currentQuoted });
}
return { ok: true, tokens };
}

View File

@@ -0,0 +1,484 @@
import { Type } from "typebox";
export const TOOL_NAME = "interactive_shell";
export const TOOL_LABEL = "Interactive Shell";
export const TOOL_DESCRIPTION = `Run an interactive CLI coding agent in an overlay.
Use this ONLY for delegating tasks to other AI coding agents (Claude Code, Cursor CLI, Gemini CLI, Codex, etc.) that have their own TUI and benefit from user interaction.
DO NOT use this for regular bash commands - use the standard bash tool instead.
MODES:
- interactive (default): User supervises and controls the session
- hands-free: Agent monitors with periodic updates, user can take over anytime by typing
- dispatch: Agent is notified on completion via triggerTurn (no polling needed)
- monitor: Run in background and wake the agent on structured monitor events (stream, poll-diff, or file-watch)
RECOMMENDED DEFAULT FOR DELEGATED TASKS:
- For fire-and-forget delegations and QA-style checks, prefer mode="dispatch".
- Dispatch is the safest choice when the agent should continue immediately and be notified automatically on completion.
The user will see the process in an overlay. They can:
- Watch output in real-time
- Scroll through output (Shift+Up/Down)
- Transfer output to you (Ctrl+T) - closes overlay and sends output as your context
- Background (Ctrl+B) - dismiss overlay, keep process running
- Detach (Ctrl+Q) for menu: transfer/background/kill
- In hands-free mode: type anything to take over control
HANDS-FREE MODE (NON-BLOCKING):
When mode="hands-free", the tool returns IMMEDIATELY with a sessionId.
The overlay opens for the user to watch, but you (the agent) get control back right away.
Workflow:
1. Start session: interactive_shell({ command: 'pi "Fix bugs"', mode: "hands-free" })
-> Returns immediately with sessionId
2. Check status/output: interactive_shell({ sessionId: "calm-reef" })
-> Returns current status and any new output since last check
3. When task is done: interactive_shell({ sessionId: "calm-reef", kill: true })
-> Kills session and returns final output
The user sees the overlay and can:
- Watch output in real-time
- Take over by typing (you'll see "user-takeover" status on next query)
- Kill/background via Ctrl+Q
QUERYING SESSION STATUS:
- interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (default: 20 lines, 5KB)
- interactive_shell({ sessionId: "calm-reef", outputLines: 50 }) - get more lines (max: 200)
- interactive_shell({ sessionId: "calm-reef", outputMaxChars: 20000 }) - get more content (max: 50KB)
- interactive_shell({ sessionId: "calm-reef", outputOffset: 0, outputLines: 50 }) - pagination (lines 0-49)
- interactive_shell({ sessionId: "calm-reef", incremental: true }) - get next N unseen lines (server tracks position)
- interactive_shell({ sessionId: "calm-reef", drain: true }) - only NEW output since last query (raw stream)
- interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
- interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
- interactive_shell({ monitorStatus: true, monitorSessionId: "calm-reef" }) - query monitor lifecycle/state
- interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef" }) - query monitor event history
- interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef", monitorSinceEventId: 42 }) - fetch events after a cursor
- interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef", monitorTriggerId: "error" }) - filter monitor history by trigger id
- interactive_shell({ monitorEvents: true, monitorSessionId: "calm-reef", monitorEventLimit: 50, monitorEventOffset: 20 }) - paginate monitor history
IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
The user is watching the overlay in real-time - you're just checking in periodically.
RATE LIMITING:
Queries are limited to once every 60 seconds (configurable). If you query too soon,
the tool will automatically wait until the limit expires before returning.
SENDING INPUT:
- interactive_shell({ sessionId: "calm-reef", input: "/help", submit: true }) - type text and press Enter
- interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] }) - named keys
- interactive_shell({ sessionId: "calm-reef", inputKeys: ["up", "up", "enter"] }) - multiple keys
- interactive_shell({ sessionId: "calm-reef", inputHex: ["0x1b", "0x5b", "0x41"] }) - raw escape sequences
- interactive_shell({ sessionId: "calm-reef", inputPaste: "multiline\\ntext" }) - bracketed paste (prevents auto-execution)
Named keys for inputKeys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
For editor-based TUIs like pi, raw \`input\` only types text. It does NOT submit by itself. Prefer \`submit: true\` or \`inputKeys: ["enter"]\` instead of relying on \`\\n\`.
TIMEOUT (for TUI commands that don't exit cleanly):
Use timeout to auto-kill after N milliseconds. Useful for capturing output from commands like "pi --help":
- interactive_shell({ command: "pi --help", mode: "hands-free", timeout: 5000 })
DISPATCH MODE (NON-BLOCKING, NO POLLING):
When mode="dispatch", the tool returns IMMEDIATELY with a sessionId.
You do NOT need to poll. You'll be notified automatically when the session completes.
Workflow:
1. Start session: interactive_shell({ command: 'pi "Fix bugs"', mode: "dispatch" })
-> Returns immediately with sessionId
2. Do other work - no polling needed
3. When complete, you receive a notification with the session output
Dispatch defaults autoExitOnQuiet to true (opt-out with handsFree.autoExitOnQuiet: false).
You can still query with sessionId if needed, but it's not required.
BACKGROUND DISPATCH (HEADLESS):
Start a session without any overlay. Process runs headlessly, agent notified on completion:
- interactive_shell({ command: 'pi "fix bugs"', mode: "dispatch", background: true })
MONITOR MODE (EVENT-DRIVEN, HEADLESS):
Run a background process and wake the agent on structured monitor triggers:
- interactive_shell({ command: 'npm test --watch', mode: "monitor", monitor: { strategy: "stream", triggers: [{ id: "fail", literal: "FAIL" }] } })
- interactive_shell({ command: 'npm run dev', mode: "monitor", monitor: { strategy: "stream", triggers: [{ id: "warn", regex: "/error|warn/i" }] } })
- interactive_shell({ command: 'curl -sf http://localhost:3000/health', mode: "monitor", monitor: { strategy: "poll-diff", triggers: [{ id: "changed", regex: "/./" }], poll: { intervalMs: 5000 } } })
- interactive_shell({ mode: "monitor", monitor: { strategy: "file-watch", fileWatch: { path: "./uploads", recursive: true, events: ["rename", "change"] }, triggers: [{ id: "pdf", regex: "/\\.pdf$/i" }] } })
AGENT-INITIATED BACKGROUND:
Dismiss an existing overlay, keep the process running in background:
- interactive_shell({ sessionId: "calm-reef", background: true })
ATTACH (REATTACH TO BACKGROUND SESSION):
Open an overlay for 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 (non-blocking, notified)
LIST BACKGROUND SESSIONS:
- interactive_shell({ listBackground: true })
DISMISS BACKGROUND SESSIONS:
- interactive_shell({ dismissBackground: true }) - kill running, remove exited, clear all
- interactive_shell({ dismissBackground: "calm-reef" }) - dismiss specific session
When using raw \`command\`, this tool does NOT inject prompts for you.
If you want to start with a prompt, include it in the command using the CLI's own prompt form.
Structured \`spawn\` also supports a \`prompt\` field for Pi, Codex, Claude, and Cursor using their native startup prompt forms.
Examples:
- pi "Scan the current codebase"
- claude "Check the current directory and summarize"
- interactive_shell({ spawn: { agent: "codex" }, mode: "dispatch" })
- interactive_shell({ spawn: { agent: "cursor", prompt: "Review the diffs" }, mode: "dispatch" })
- interactive_shell({ spawn: { agent: "claude", prompt: "Review the diffs" }, mode: "dispatch" })
- interactive_shell({ spawn: { mode: "fork" } }) // pi-only fork of the current persisted session
- gemini (interactive, idle)
- aider --yes-always (hands-free, auto-approve)
- pi --help (with timeout: 5000 to capture help output)`;
export const toolParameters = Type.Object({
command: Type.Optional(
Type.String({
description: "The raw CLI command to run (e.g., 'pi \"Fix the bug\"'). Use this for arbitrary CLIs. Mutually exclusive with 'spawn'.",
}),
),
spawn: Type.Optional(
Type.Object({
agent: Type.Optional(Type.Union([
Type.Literal("pi"),
Type.Literal("codex"),
Type.Literal("claude"),
Type.Literal("cursor"),
], {
description: "Spawn agent to launch. Defaults to the configured spawn.defaultAgent.",
})),
mode: Type.Optional(Type.Union([
Type.Literal("fresh"),
Type.Literal("fork"),
], {
description: "Spawn mode. 'fork' is only supported for pi and requires a persisted current session.",
})),
worktree: Type.Optional(Type.Boolean({
description: "Launch in a separate git worktree. Defaults to spawn.worktree from config.",
})),
prompt: Type.Optional(Type.String({
description: "Optional startup prompt for pi, codex, claude, or cursor. Uses each CLI's native prompt-bearing startup form.",
})),
}, {
description: "Structured spawn request for pi, codex, claude, or cursor. Use this instead of building the command string manually when you want the extension's spawn defaults, Pi-only fork behavior, worktree support, or native startup prompts.",
}),
),
sessionId: Type.Optional(
Type.String({
description: "Session ID to interact with an existing hands-free session",
}),
),
kill: Type.Optional(
Type.Boolean({
description: "Kill the session (requires sessionId). Use when task appears complete.",
}),
),
outputLines: Type.Optional(
Type.Number({
description: "Number of lines to return when querying (default: 20, max: 200)",
}),
),
outputMaxChars: Type.Optional(
Type.Number({
description: "Max chars to return when querying (default: 5KB, max: 50KB)",
}),
),
outputOffset: Type.Optional(
Type.Number({
description: "Line offset for pagination (0-indexed). Use with outputLines to read specific ranges.",
}),
),
drain: Type.Optional(
Type.Boolean({
description: "If true, return only NEW output since last query (raw stream). More token-efficient for repeated polling.",
}),
),
incremental: Type.Optional(
Type.Boolean({
description: "If true, return next N lines not yet seen. Server tracks position - just keep calling to paginate through output.",
}),
),
settings: Type.Optional(
Type.Object({
updateInterval: Type.Optional(
Type.Number({ description: "Change max update interval for existing session (ms)" }),
),
quietThreshold: Type.Optional(
Type.Number({ description: "Change quiet threshold for existing session (ms)" }),
),
}),
),
input: Type.Optional(
Type.String({ description: "Raw text to send to the session (requires sessionId). This only types the text; it does not submit it. Use submit=true or inputKeys:['enter'] when you want to press Enter." }),
),
submit: Type.Optional(
Type.Boolean({ description: "Press Enter after sending any input. Prefer this when submitting slash commands or prompts to editor-based TUIs like pi. (requires sessionId)" }),
),
inputKeys: Type.Optional(
Type.Array(Type.String(), {
description: "Named keys with modifier support: up, down, enter, ctrl+c, alt+x, shift+tab, ctrl+alt+delete, etc. (requires sessionId)",
}),
),
inputHex: Type.Optional(
Type.Array(Type.String(), {
description: "Hex bytes to send as raw escape sequences (e.g., ['0x1b', '0x5b', '0x41'] for ESC[A). (requires sessionId)",
}),
),
inputPaste: Type.Optional(
Type.String({
description: "Text to paste with bracketed paste mode - prevents shells from auto-executing multiline input. (requires sessionId)",
}),
),
cwd: Type.Optional(
Type.String({
description: "Working directory for the command",
}),
),
name: Type.Optional(
Type.String({
description: "Optional session name (used for session IDs)",
}),
),
reason: Type.Optional(
Type.String({
description:
"Brief explanation shown in the overlay header only (not passed to the subprocess)",
}),
),
mode: Type.Optional(
Type.Union([
Type.Literal("interactive"),
Type.Literal("hands-free"),
Type.Literal("dispatch"),
Type.Literal("monitor"),
], {
description: "Mode: 'interactive' (default, user controls), 'hands-free' (agent monitors, user can take over), 'dispatch' (agent notified on completion, no polling needed), or 'monitor' (headless structured event monitor with stream/poll-diff/file-watch strategies).",
}),
),
monitor: Type.Optional(
Type.Object({
strategy: Type.Optional(Type.Union([
Type.Literal("stream"),
Type.Literal("poll-diff"),
Type.Literal("file-watch"),
], {
description: "Monitor strategy. stream = line-based trigger matching. poll-diff = periodic snapshot diffing. file-watch = first-class filesystem watch events.",
})),
triggers: Type.Array(Type.Object({
id: Type.String({ description: "Unique trigger id used in emitted event payloads." }),
literal: Type.Optional(Type.String({ description: "Literal substring trigger." })),
regex: Type.Optional(Type.String({ description: "Regex trigger string. Supports /pattern/flags format." })),
cooldownMs: Type.Optional(Type.Number({ description: "Optional per-trigger cooldown window in ms." })),
threshold: Type.Optional(Type.Object({
captureGroup: Type.Number({ description: "Regex capture group index parsed as number (requires regex matcher)." }),
op: Type.Union([
Type.Literal("lt"),
Type.Literal("lte"),
Type.Literal("gt"),
Type.Literal("gte"),
], { description: "Threshold operator." }),
value: Type.Number({ description: "Threshold numeric value." }),
})),
}), {
description: "Named trigger definitions. Each trigger must define exactly one matcher: literal or regex.",
}),
fileWatch: Type.Optional(Type.Object({
path: Type.String({ description: "Path to watch for strategy='file-watch'. Relative paths resolve from cwd." }),
recursive: Type.Optional(Type.Boolean({ description: "Watch subdirectories recursively (platform-dependent support)." })),
events: Type.Optional(Type.Array(Type.Union([
Type.Literal("rename"),
Type.Literal("change"),
]), { description: "Filesystem event names to emit." })),
})),
poll: Type.Optional(Type.Object({
intervalMs: Type.Optional(Type.Number({ description: "Poll interval in ms for strategy='poll-diff' (default: 5000)." })),
})),
persistence: Type.Optional(Type.Object({
stopAfterFirstEvent: Type.Optional(Type.Boolean({ description: "Stop monitor after first emitted event." })),
maxEvents: Type.Optional(Type.Number({ description: "Maximum emitted events before monitor stops." })),
})),
throttle: Type.Optional(Type.Object({
dedupeExactLine: Type.Optional(Type.Boolean({ description: "Suppress repeated exact line/diff payloads (default: true)." })),
cooldownMs: Type.Optional(Type.Number({ description: "Optional global cooldown in ms across triggers." })),
})),
detector: Type.Optional(Type.Object({
detectorCommand: Type.String({ description: "External detector command. Receives JSON candidate event on stdin and returns JSON decision on stdout." }),
timeoutMs: Type.Optional(Type.Number({ description: "Detector command timeout in ms (default: 3000)." })),
})),
}, {
description: "Structured monitor configuration required when mode='monitor'.",
}),
),
background: Type.Optional(
Type.Boolean({
description: "Run without overlay (with mode='dispatch' or mode='monitor') or dismiss existing overlay (with sessionId). Process runs in background, user can /attach.",
}),
),
attach: Type.Optional(
Type.String({
description: "Background session ID to reattach. Opens overlay with the specified mode.",
}),
),
listBackground: Type.Optional(
Type.Boolean({
description: "List all background sessions.",
}),
),
dismissBackground: Type.Optional(
Type.Union([Type.Boolean(), Type.String()], {
description: "Dismiss background sessions. true = all, string = specific session ID. Kills running sessions, removes exited ones.",
}),
),
monitorStatus: Type.Optional(
Type.Boolean({
description: "Query monitor lifecycle/state summary. Requires monitorSessionId or sessionId.",
}),
),
monitorEvents: Type.Optional(
Type.Boolean({
description: "Query structured monitor event history instead of session output. Requires monitorSessionId or sessionId.",
}),
),
monitorSessionId: Type.Optional(
Type.String({
description: "Target monitor session for monitorStatus/monitorEvents queries.",
}),
),
monitorEventLimit: Type.Optional(
Type.Number({
description: "Max monitor events to return (default: 20).",
}),
),
monitorEventOffset: Type.Optional(
Type.Number({
description: "How many newest monitor events to skip before returning results (default: 0).",
}),
),
monitorSinceEventId: Type.Optional(
Type.Number({
description: "Only return monitor events with eventId greater than this cursor.",
}),
),
monitorTriggerId: Type.Optional(
Type.String({
description: "Filter monitor events to a specific trigger id.",
}),
),
handsFree: Type.Optional(
Type.Object({
updateMode: Type.Optional(
Type.String({
description: "Update mode: 'on-quiet' (default, emit when output stops) or 'interval' (emit on fixed schedule)",
}),
),
updateInterval: Type.Optional(
Type.Number({ description: "Max interval between updates in ms (default: 60000)" }),
),
quietThreshold: Type.Optional(
Type.Number({ description: "Silence duration before emitting update in on-quiet mode (default: 8000ms)" }),
),
gracePeriod: Type.Optional(
Type.Number({ description: "Startup grace period before autoExitOnQuiet can kill the session (default: 15000ms)" }),
),
updateMaxChars: Type.Optional(
Type.Number({ description: "Max chars per update (default: 1500)" }),
),
maxTotalChars: Type.Optional(
Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
),
autoExitOnQuiet: Type.Optional(
Type.Boolean({
description: "Auto-kill session when output stops (after quietThreshold). Defaults to false. Set to true for fire-and-forget single-task delegations.",
}),
),
}),
),
handoffPreview: Type.Optional(
Type.Object({
enabled: Type.Optional(Type.Boolean({ description: "Include last N lines in tool result details" })),
lines: Type.Optional(Type.Number({ description: "Tail lines to include (default from config)" })),
maxChars: Type.Optional(
Type.Number({ description: "Max chars to include in tail preview (default from config)" }),
),
}),
),
handoffSnapshot: Type.Optional(
Type.Object({
enabled: Type.Optional(Type.Boolean({ description: "Write a transcript snapshot on detach/exit" })),
lines: Type.Optional(Type.Number({ description: "Tail lines to capture (default from config)" })),
maxChars: Type.Optional(Type.Number({ description: "Max chars to write (default from config)" })),
}),
),
timeout: Type.Optional(
Type.Number({
description: "Auto-kill process after N milliseconds. Useful for TUI commands that don't exit cleanly (e.g., 'pi --help')",
}),
),
});
/** Parsed tool parameters type */
export interface ToolParams {
command?: string;
spawn?: { agent?: "pi" | "codex" | "claude" | "cursor"; mode?: "fresh" | "fork"; worktree?: boolean; prompt?: string };
sessionId?: string;
kill?: boolean;
outputLines?: number;
outputMaxChars?: number;
outputOffset?: number;
drain?: boolean;
incremental?: boolean;
settings?: { updateInterval?: number; quietThreshold?: number };
input?: string;
submit?: boolean;
inputKeys?: string[];
inputHex?: string[];
inputPaste?: string;
cwd?: string;
name?: string;
reason?: string;
mode?: "interactive" | "hands-free" | "dispatch" | "monitor";
background?: boolean;
monitor?: {
strategy?: "stream" | "poll-diff" | "file-watch";
triggers: Array<{
id: string;
literal?: string;
regex?: string;
cooldownMs?: number;
threshold?: { captureGroup: number; op: "lt" | "lte" | "gt" | "gte"; value: number };
}>;
fileWatch?: { path: string; recursive?: boolean; events?: Array<"rename" | "change"> };
poll?: { intervalMs?: number };
persistence?: { stopAfterFirstEvent?: boolean; maxEvents?: number };
throttle?: { dedupeExactLine?: boolean; cooldownMs?: number };
detector?: { detectorCommand: string; timeoutMs?: number };
};
attach?: string;
listBackground?: boolean;
dismissBackground?: boolean | string;
monitorStatus?: boolean;
monitorEvents?: boolean;
monitorSessionId?: string;
monitorEventLimit?: number;
monitorEventOffset?: number;
monitorSinceEventId?: number;
monitorTriggerId?: string;
handsFree?: {
updateMode?: "on-quiet" | "interval";
updateInterval?: number;
quietThreshold?: number;
gracePeriod?: number;
updateMaxChars?: number;
maxTotalChars?: number;
autoExitOnQuiet?: boolean;
};
handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
timeout?: number;
}

View File

@@ -0,0 +1,198 @@
/**
* 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" | "agent-resumed";
sessionId: string;
runtime: number;
tail: string[];
tailTruncated: boolean;
userTookOver?: boolean;
// Budget tracking
totalCharsSent?: number;
budgetExhausted?: boolean;
}
export type MonitorStrategy = "stream" | "poll-diff" | "file-watch";
export type MonitorThresholdOperator = "lt" | "lte" | "gt" | "gte";
export interface MonitorThresholdConfig {
captureGroup: number;
op: MonitorThresholdOperator;
value: number;
}
export interface MonitorTriggerConfig {
id: string;
literal?: string;
regex?: string;
cooldownMs?: number;
threshold?: MonitorThresholdConfig;
}
export interface MonitorFileWatchConfig {
path: string;
recursive?: boolean;
events?: Array<"rename" | "change">;
}
export interface MonitorConfig {
strategy?: MonitorStrategy;
triggers: MonitorTriggerConfig[];
fileWatch?: MonitorFileWatchConfig;
poll?: {
intervalMs?: number;
};
persistence?: {
stopAfterFirstEvent?: boolean;
maxEvents?: number;
};
throttle?: {
dedupeExactLine?: boolean;
cooldownMs?: number;
};
detector?: {
detectorCommand: string;
timeoutMs?: number;
};
}
export interface MonitorEventPayload {
sessionId: string;
eventId: number;
timestamp: string;
strategy: MonitorStrategy;
triggerId: string;
eventType: string;
matchedText: string;
lineOrDiff: string;
stream: "pty";
}
export type MonitorTerminalReason = "stream-ended" | "script-failed" | "stopped" | "timed-out";
export interface MonitorSessionState {
sessionId: string;
strategy: MonitorStrategy;
triggerIds: string[];
status: "running" | "stopped";
eventCount: number;
startedAt: string;
lastEventId?: number;
lastEventAt?: string;
lastTriggerId?: string;
endedAt?: string;
terminalReason?: MonitorTerminalReason;
exitCode?: number | null;
signal?: number;
}
/** Options for starting or reattaching an interactive shell session. */
export interface InteractiveShellOptions {
command: string;
cwd?: string;
name?: string;
reason?: string;
/** Original session start time in ms since epoch, preserved across background/reattach transitions. */
startedAt?: number;
handoffPreviewEnabled?: boolean;
handoffPreviewLines?: number;
handoffPreviewMaxChars?: number;
handoffSnapshotEnabled?: boolean;
handoffSnapshotLines?: number;
handoffSnapshotMaxChars?: number;
// Hands-free / dispatch / monitor mode
mode?: "interactive" | "hands-free" | "dispatch" | "monitor";
monitor?: MonitorConfig;
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;
autoExitGracePeriod?: number;
// Auto-kill timeout
timeout?: number;
// When true, unregister active session on completion (blocking tool call path).
// When false/undefined, keep registered so agent can query result later.
streamingMode?: boolean;
// Existing PTY session (for attach flow -- skip creating a new PTY)
existingSession?: import("./pty-session.js").PtyTerminalSession;
onUnfocus?: () => void;
}
export type DialogChoice = "kill" | "background" | "transfer" | "cancel" | "return-to-agent";
export type OverlayState = "running" | "exited" | "detach-dialog" | "hands-free";
// UI constants
export const FOOTER_LINES_COMPACT = 2;
export const FOOTER_LINES_DIALOG = 6;
export const HEADER_LINES = 4;
/** 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 a key shortcut string for display (capitalize modifier names) */
export function formatShortcut(shortcut: string): string {
return shortcut
.replace(/ctrl/gi, "Ctrl")
.replace(/shift/gi, "Shift")
.replace(/alt/gi, "Alt");
}
/** 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`;
}