Files
Codeman/src/mux-interface.ts
T
Codeman maintainer c5b59633d8 feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206)
SessionMode gains 'pi', a first-class backend alongside Claude Code,
OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose
tab identity, welcome button, run-mode entry, cron agentType, Docker and
remote-SSH command defaults, and clone-repo Brain option.

Pi is a different shape of CLI from the other four, and three decisions
follow from that:

- It has NO permission prompts and no sandbox, so there is no
  --dangerously-skip-permissions analog and none was invented. The
  privilege-shaped knob is the tri-state approveProjectTrust, which makes
  pi load and EXECUTE repo-local .pi/extensions TypeScript and install
  missing project packages. clampExternalCliBypassForOwner() therefore
  puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets
  --no-approve even when no config was sent, because pi's own default is
  a prompt the session user could answer themselves. That helper had zero
  test coverage; it now has coverage for all four CLIs.
- Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars
  share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode
  context, so admitting them would widen the allowlist for every mode at
  once. Auth goes through pi's /login or the server's own environment.
  --api-key is deliberately never wired: it would put a provider secret on
  the spawn command line.
- pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the
  main screen with terminal-owned scrollback, and its 0.84.0 fullscreen
  mode is runtime-switchable via /settings; that flip was measured to put
  the pane into the alt screen, which the strip would have corrupted.

pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires
semver-shaped output, because `pi` is a short generic name a stray binary
can shadow; GET /api/pi/status surfaces path and version so a
misresolution is diagnosable rather than presenting as a broken mode.

Docker installs pi in its own --ignore-scripts step so that flag cannot
affect the other four CLIs, and seeds its credentials per-file rather than
whole-dir (~/.pi/agent also holds sessions, extensions and package trees).

Verified end to end against pi 0.84.1 on an isolated instance: resolver
search-dir fallback, flag construction, piConfig persistence across a full
server restart, the trust prompt and its --no-approve suppression, the
rose Run button on the default daylight-blue skin (the nested skin block
eats per-mode gradients unless the rule lives inside it), and the buffer
local-echo policy, which pi tolerates where codex did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:54:47 +02:00

292 lines
9.9 KiB
TypeScript

/**
* @fileoverview Terminal multiplexer abstraction layer (tmux).
*
* Defines the TerminalMultiplexer interface that TmuxManager implements.
*
* @module mux-interface
*/
import type { EventEmitter } from 'node:events';
import type {
ProcessStats,
PersistedRespawnConfig,
NiceConfig,
ClaudeMode,
SessionMode,
OpenCodeConfig,
CodexConfig,
EffortLevel,
GeminiConfig,
AntigravityConfig,
PiConfig,
SessionRemote,
SessionDocker,
} from './types.js';
/**
* Multiplexer session metadata.
*/
export interface MuxSession {
/** Codeman session ID */
sessionId: string;
/** Multiplexer session name (e.g., "codeman-abc12345") */
muxName: string;
/** Process PID */
pid: number;
/** Timestamp when created */
createdAt: number;
/** Working directory */
workingDir: string;
/** Remote execution metadata for local tmux sessions wrapping SSH */
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
owner?: string;
/** Session mode */
mode: SessionMode;
/** Whether webserver is attached to this session */
attached: boolean;
/** Session display name (tab name) */
name?: string;
/** Persisted respawn controller configuration (restored on server restart) */
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
}
/**
* MuxSession with optional process resource statistics.
*/
export interface MuxSessionWithStats extends MuxSession {
/** Optional resource statistics */
stats?: ProcessStats;
}
/** Options for creating a new multiplexer session. */
export interface CreateSessionOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
name?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
/** tmux history-limit (scrollback lines) to set for this session. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Owning username in multi-user mode; persisted for recovery. */
owner?: string;
}
/** Options for respawning a dead pane. */
export interface RespawnPaneOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
name?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
owner?: string;
}
/** Options for pane buffer capture (COD-47 full-history mode). */
export interface PaneCaptureOptions {
/** Capture the entire tmux scrollback instead of just the visible frame. */
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
/**
* Byte cap the consumer will keep from the capture. Sizes the child-process
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
* the 1MB execSync default (ENOBUFS).
*/
maxCaptureBytes?: number;
}
/**
* Terminal multiplexer interface.
*
* Implemented by TmuxManager.
*
* Events emitted:
* - `sessionCreated` (session: MuxSession) - New session created
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ==========
/**
* Create a new multiplexer session.
* The session runs the appropriate command (claude, opencode, or shell) in detached mode.
*/
createSession(options: CreateSessionOptions): Promise<MuxSession>;
/**
* Kill a session and all its child processes.
* Uses a multi-strategy approach (children → process group → mux kill → SIGKILL).
*/
killSession(sessionId: string): Promise<boolean>;
/** Clean up resources (stop stats collection, etc.) */
destroy(): void;
// ========== Queries ==========
/** Get all tracked sessions */
getSessions(): MuxSession[];
/** Get a session by Codeman session ID */
getSession(sessionId: string): MuxSession | undefined;
/** Get all sessions with process resource statistics */
getSessionsWithStats(): Promise<MuxSessionWithStats[]>;
/** Get process stats for a single session */
getProcessStats(sessionId: string): Promise<ProcessStats | null>;
// ========== Input ==========
/**
* Send input to a session via tmux send-keys.
*/
sendInput(sessionId: string, input: string): Promise<boolean>;
// ========== Metadata ==========
/** Update the display name of a session */
updateSessionName(sessionId: string, name: string): boolean;
/** Mark session as attached/detached */
setAttached(sessionId: string, attached: boolean): void;
/** Register an externally-created session for tracking */
registerSession(session: MuxSession): void;
/** Update persisted respawn config for a session */
updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void;
/** Clear respawn config when respawn is stopped */
clearRespawnConfig(sessionId: string): void;
/** Update Ralph enabled state for a session */
updateRalphEnabled(sessionId: string, enabled: boolean): void;
/** Apply a tmux history-limit to all tracked sessions. */
setHistoryLimit(limit: number): Promise<void>;
// ========== Discovery ==========
/**
* Reconcile tracked sessions with actual running sessions.
* Finds dead sessions and discovers unknown ones.
*/
reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }>;
// ========== Stats Collection ==========
/** Start periodic process stats collection */
startStatsCollection(intervalMs?: number): void;
/** Stop periodic process stats collection */
stopStatsCollection(): void;
// ========== PTY Attachment ==========
/**
* Get the command to spawn for attaching to a session ('tmux').
*/
getAttachCommand(): string;
/**
* Get the arguments for attaching to a session by mux name.
*/
getAttachArgs(muxName: string): string[];
/** Pin a mux window so client attaches do not automatically dictate its size. */
setManualWindowSize?(muxName: string): boolean;
/** Explicitly resize a mux window after Codeman accepts a terminal resize. */
resizeWindow?(muxName: string, cols: number, rows: number): boolean;
// ========== Availability ==========
/** Check if the multiplexer binary is available on the system */
isAvailable(): boolean;
/** Check if a multiplexer session actually exists (process-level check, not just tracked) */
muxSessionExists(muxName: string): boolean;
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
isPaneDead(muxName: string): boolean;
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
/**
* Capture a pane's current tmux buffer with ANSI escape codes preserved.
* Pass `{ fullHistory: true }` to capture the entire scrollback as linear
* text instead of just the visible single-screen frame (COD-47).
*/
capturePaneBuffer?(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null;
/**
* Capture the active pane's current tmux buffer with ANSI escape codes preserved.
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
*/
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
/**
* Plain text of the visible frame: no styles, no cursor query, no repaint
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
* detection calls it on a timer: it only needs to read what the CLI is
* currently rendering, never to replay it into an xterm. Returns null when the
* pane cannot be read.
*/
capturePaneText?(muxName: string, paneTarget?: string): string | null;
}