mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
The Remote case form could only reach port-22, default-identity, directly
SSH-able hosts. Add an escape-hatch set of SSH connection options so Codeman
can reach a host like aa-desktop (custom port 2222, ed25519 identity, cloudflared
SOCKS5 ProxyCommand) the way ssh-aa-desktop does — without shelling out to that
wrapper.
- Model (types/session.ts): new optional RemoteSshOptions (identityFile,
socksProxy, jumpHost, extraSshOptions) on RemoteHost AND SessionRemote; all
absent = today's behavior. toSessionRemote() carries them case->session.
- Shared buildSshConnectionArgs(remote) in remote-hosts.ts: pure, exported,
ordered ssh connection tokens (-o BatchMode=yes, -p, -i <abs identity with
~/$HOME expanded + shellescaped>, -J, -o ProxyCommand=nc -X 5 -x <socks>
%h %p emitted as ONE shellescaped token so %h %p reach ssh literally, then
each extraSshOptions -o). Both buildRemoteLaunchCommand (tmux-manager.ts) and
buildRemoteTmuxCheckCommand now use it, so the prereq probe and the real
launch connect identically. checkRemoteTmuxAvailable widened to accept the
options (callers already pass the full host).
- Validation (schemas.ts): identityFile (no newline/NUL), socksProxy
(host:port), jumpHost (no shell metachars), extraSshOptions (KEY=VALUE,
reject newline/NUL/backtick/$() — defense-in-depth on operator-entered config.
- UI (index.html + session-ui.js): SSH Port field + collapsible "Advanced SSH"
section (identity, SOCKS proxy, jump host, extra -o options one per line);
wired into the remote-host create payload.
Empty-options remotes emit byte-identical ssh to before (pinned by test).
Tests: test/remote-ssh-options.test.ts (buildSshConnectionArgs +
buildRemoteLaunchCommand + buildRemoteTmuxCheckCommand for the aa-desktop set,
escaping/%h %p/identity-~ expansion, byte-identical back-compat); case-routes
schema tests (advanced options round-trip; malformed extraSshOptions/socksProxy
rejected). tsc/eslint/frontend-syntax/prettier/build clean.
Acceptance (real remote, no wrapper): the emitted command connected to
aa-desktop through the cloudflared SOCKS proxy and created a durable remote
tmux session (verified independently via ssh-aa-desktop: CONNECTED_NO_WRAPPER,
STILL_ALIVE_AFTER_DETACH); checkRemoteTmuxAvailable over the proxy returned
{ok:true, tmuxPath:/usr/local/bin/tmux}; test session cleaned up.
241 lines
7.7 KiB
TypeScript
241 lines
7.7 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,
|
|
SessionRemote,
|
|
} 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;
|
|
/** 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;
|
|
/** 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;
|
|
}
|
|
|
|
/** Options for respawning a dead pane. */
|
|
export interface RespawnPaneOptions {
|
|
sessionId: string;
|
|
workingDir: string;
|
|
mode: SessionMode;
|
|
niceConfig?: NiceConfig;
|
|
model?: string;
|
|
claudeMode?: ClaudeMode;
|
|
allowedTools?: string;
|
|
openCodeConfig?: OpenCodeConfig;
|
|
codexConfig?: CodexConfig;
|
|
geminiConfig?: GeminiConfig;
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* 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. */
|
|
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
|
|
|
|
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
|
|
captureActivePaneBuffer?(muxName: string): string | null;
|
|
}
|