diff --git a/packages/desktop/src/main/exec-env.ts b/packages/desktop/src/main/exec-env.ts index 544e4b8..9eea284 100644 --- a/packages/desktop/src/main/exec-env.ts +++ b/packages/desktop/src/main/exec-env.ts @@ -1,10 +1,15 @@ import { exec } from 'child_process' +import { homedir } from 'os' +import { join } from 'path' import { promisify } from 'util' // パッケージ化アプリは .zshrc 等を読まず PATH が限定されるため明示的に指定する +const home = homedir() export const EXEC_ENV = { ...process.env, PATH: [ + join(home, '.local/bin'), + join(home, '.cargo/bin'), '/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index ae9df55..f08db13 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -197,7 +197,7 @@ function setupIpc(getToken: () => string) { return desktopCreateSession(source) }) - /** tmux / screen / zellij のセッション一覧を返す */ + /** マルチプレクサのセッション一覧を返す */ ipcMain.handle('get-multiplexer-sessions', () => { return getMultiplexerSessions() }) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index 123c1ec..22697ce 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -4,13 +4,21 @@ import type { IncomingMessage } from 'http' import { existsSync, readdirSync, statSync } from 'fs' import { join } from 'path' import { homedir, hostname as osHostname } from 'os' -import { WsMessage, SessionInfo, ProjectInfo, MultiplexerSessionInfo, SessionSource, DEFAULT_WS_PORT } from '@remocoder/shared' +import { WsMessage, SessionInfo, ProjectInfo, MultiplexerSessionInfo, SessionSource, MultiplexerSource, MultiplexerKind, DEFAULT_WS_PORT, MULTIPLEXER_KINDS, isMultiplexerKind, isMultiplexerSource } from '@remocoder/shared' import { v4 as uuidv4 } from 'uuid' import { tryParsePermission, stripAnsi } from './permission-parser' import { execAsync, EXEC_ENV } from './exec-env' let AUTH_TOKEN = process.env.REMOTE_TOKEN ?? uuidv4() const SERVER_NAME = osHostname() +const ALLOWED_SOURCE_KINDS = ['claude', ...MULTIPLEXER_KINDS, 'shell'] as const + +const MUX_SPAWN_CONFIG: Record string[] }> = { + tmux: { binary: 'tmux', args: (n) => ['attach-session', '-t', n] }, + screen: { binary: 'screen', args: (n) => ['-r', n] }, + zellij: { binary: 'zellij', args: (n) => ['attach', n] }, + herdr: { binary: 'herdr', args: (n) => ['session', 'attach', n] }, +} // ─── Claude プロジェクト一覧取得 ─────────────────────────────────────────────── @@ -166,6 +174,18 @@ interface PtySession { /** 永続PTYセッションマップ(WS切断後も保持) */ const ptySessions = new Map() + +function findExistingMuxSession(source: MultiplexerSource): PtySession | undefined { + for (const s of ptySessions.values()) { + if (s.source && isMultiplexerSource(s.source) && + s.source.kind === source.kind && + s.source.sessionName === source.sessionName) { + return s + } + } + return undefined +} + /** 認証済みかつ未アタッチのモバイル picker 接続セット */ const pickerSockets = new Set() @@ -517,13 +537,10 @@ function createExternalSession(providerWs: WebSocket): PtySession { /** デスクトップから新規PTYセッションを作成する */ export function desktopCreateSession(source: SessionSource = { kind: 'claude' }): string { // マルチプレクサの場合、同じセッションにアタッチ済みのPTYセッションがあれば再利用する - if (source.kind === 'tmux' || source.kind === 'screen' || source.kind === 'zellij') { - const { kind, sessionName } = source - const existing = Array.from(ptySessions.values()).find( - (s) => s.source?.kind === kind && (s.source as typeof source).sessionName === sessionName, - ) + if (isMultiplexerSource(source)) { + const existing = findExistingMuxSession(source) if (existing) { - console.log(`[pty-server] Reusing existing PTY session ${existing.id.slice(0, 8)} for ${kind}:${sessionName}`) + console.log(`[pty-server] Reusing existing PTY session ${existing.id.slice(0, 8)} for ${source.kind}:${source.sessionName}`) return existing.id } } @@ -749,11 +766,11 @@ export function startPtyServer(port = DEFAULT_WS_PORT, callbacks: PtyServerCallb detachFromSession() // source が指定されていればそれを使用、なければ後方互換で claude として扱う const rawSource = msg.source ?? { kind: 'claude', projectPath: msg.projectPath } - const allowedKinds = ['claude', 'tmux', 'screen', 'zellij', 'shell'] as const - const isMultiplexer = rawSource.kind === 'tmux' || rawSource.kind === 'screen' || rawSource.kind === 'zellij' + const isMultiplexer = isMultiplexerKind(rawSource.kind) + const rawName = (rawSource as Record).sessionName const isInvalid = - !allowedKinds.includes(rawSource.kind as (typeof allowedKinds)[number]) || - (isMultiplexer && (typeof rawSource.sessionName !== 'string' || rawSource.sessionName.length === 0)) + !ALLOWED_SOURCE_KINDS.includes(rawSource.kind as (typeof ALLOWED_SOURCE_KINDS)[number]) || + (isMultiplexer && (typeof rawName !== 'string' || rawName.length === 0)) if (isInvalid) { console.warn(`[pty-server] Rejected session_create: invalid source ${JSON.stringify(rawSource)}`) ws.send(JSON.stringify({ type: 'auth_error', reason: 'invalid session source' } satisfies WsMessage)) @@ -762,12 +779,8 @@ export function startPtyServer(port = DEFAULT_WS_PORT, callbacks: PtyServerCallb const source = rawSource as SessionSource pickerSockets.delete(ws) // マルチプレクサは同名セッションが既存なら再利用する(Desktop と同じ挙動) - const existingMux = isMultiplexer - ? Array.from(ptySessions.values()).find( - (s) => s.source?.kind === source.kind && - (s.source as Extract).sessionName === - (source as Extract).sessionName, - ) + const existingMux = isMultiplexerSource(source) + ? findExistingMuxSession(source) : undefined const session = existingMux ?? createPtySession(source, clientIP) // 既存セッションを再利用する場合は session_attach と同じ手順で安全にアタッチする @@ -980,7 +993,7 @@ function assertSafeSessionName(name: string, tool: string): void { } function spawnSource(source: SessionSource): pty.IPty { - const baseOpts = { name: 'xterm-color', cols: 80, rows: 30, env: { ...process.env } } + const baseOpts = { name: 'xterm-color', cols: 80, rows: 30, env: { ...EXEC_ENV } } switch (source.kind) { case 'claude': { const loginShell = resolveShell() @@ -988,20 +1001,14 @@ function spawnSource(source: SessionSource): pty.IPty { console.log(`[pty-server] Spawning claude via shell: ${loginShell}${cwd ? ` (cwd: ${cwd})` : ''}`) return pty.spawn(loginShell, ['-lc', 'exec claude'], { ...baseOpts, ...(cwd ? { cwd } : {}) }) } - case 'tmux': { - assertSafeSessionName(source.sessionName, 'tmux') - console.log(`[pty-server] Attaching to tmux session: ${source.sessionName}`) - return pty.spawn('tmux', ['attach-session', '-t', source.sessionName], baseOpts) - } - case 'screen': { - assertSafeSessionName(source.sessionName, 'screen') - console.log(`[pty-server] Attaching to screen session: ${source.sessionName}`) - return pty.spawn('screen', ['-r', source.sessionName], baseOpts) - } - case 'zellij': { - assertSafeSessionName(source.sessionName, 'zellij') - console.log(`[pty-server] Attaching to zellij session: ${source.sessionName}`) - return pty.spawn('zellij', ['attach', source.sessionName], baseOpts) + case 'tmux': + case 'screen': + case 'zellij': + case 'herdr': { + assertSafeSessionName(source.sessionName, source.kind) + console.log(`[pty-server] Attaching to ${source.kind} session: ${source.sessionName}`) + const { binary, args } = MUX_SPAWN_CONFIG[source.kind] + return pty.spawn(binary, args(source.sessionName), baseOpts) } case 'shell': { const loginShell = resolveShell() @@ -1009,21 +1016,21 @@ function spawnSource(source: SessionSource): pty.IPty { console.log(`[pty-server] Spawning shell: ${loginShell}${cwd ? ` (cwd: ${cwd})` : ''}`) return pty.spawn(loginShell, ['-l'], { ...baseOpts, ...(cwd ? { cwd } : {}) }) } - default: + default: { + const _exhaustive: never = source throw new Error(`[pty-server] Unknown session source kind: ${(source as { kind: string }).kind}`) + } } } // ─── マルチプレクサセッション一覧取得 ──────────────────────────────────────── -/** 利用可能な tmux / screen / zellij セッション一覧を取得する */ +/** 利用可能なマルチプレクサセッション一覧を取得する */ export async function getMultiplexerSessions(): Promise { - const results: MultiplexerSessionInfo[] = [] - - // tmux - // TERM を明示的に設定することで tmux の vis(3) エンコードを抑制し、 - // タブ区切り出力が正しく得られるようにする(GUI 起動時は TERM が未設定になる) - try { + async function collectTmux(): Promise { + const results: MultiplexerSessionInfo[] = [] + // TERM を明示的に設定することで tmux の vis(3) エンコードを抑制し、 + // タブ区切り出力が正しく得られるようにする(GUI 起動時は TERM が未設定になる) const { stdout } = await execAsync( 'tmux list-panes -a -F "#{session_name}\t#{session_windows}\t#{pane_current_path}"', { env: { ...EXEC_ENV, TERM: 'xterm-256color' } }, @@ -1043,12 +1050,11 @@ export async function getMultiplexerSessions(): Promise { + const results: MultiplexerSessionInfo[] = [] // screen -ls は接続中セッションがある場合に exit code 1 を返すため stdout を取り出す const screenOutput = await execAsync('screen -ls', { env: EXEC_ENV }).then( (r) => r.stdout, @@ -1062,23 +1068,44 @@ export async function getMultiplexerSessions(): Promise { + const results: MultiplexerSessionInfo[] = [] const { stdout } = await execAsync('zellij list-sessions', { env: EXEC_ENV }) for (const line of stdout.trim().split('\n').filter(Boolean)) { - // "session-name [Created...]" 形式の場合もあるため最初のトークンだけ取得 const sessionName = line.trim().split(/\s+/)[0] if (sessionName && SAFE_SESSION_NAME_RE.test(sessionName)) { results.push({ tool: 'zellij', sessionName }) } } - } catch { - // zellij未インストールまたはセッションなし + return results } - return results + async function collectHerdr(): Promise { + const results: MultiplexerSessionInfo[] = [] + const { stdout } = await execAsync('herdr session list --json', { env: EXEC_ENV }) + const parsed = JSON.parse(stdout) + const sessions: Array<{ name: string; running: boolean; session_dir?: string }> = + Array.isArray(parsed) ? parsed : parsed.sessions ?? [] + for (const s of sessions) { + if (!s.name || !SAFE_SESSION_NAME_RE.test(s.name)) continue + results.push({ + tool: 'herdr', + sessionName: s.name, + detail: s.running ? 'running' : 'stopped', + }) + } + return results + } + + const collectors: Record Promise> = { + tmux: collectTmux, + screen: collectScreen, + zellij: collectZellij, + herdr: collectHerdr, + } + const settled = await Promise.allSettled(MULTIPLEXER_KINDS.map((k) => collectors[k]())) + return settled.flatMap((r) => r.status === 'fulfilled' ? r.value : []) } diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 4a59e53..a3b143a 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -27,7 +27,7 @@ contextBridge.exposeInMainWorld('electronAPI', { /** 新規PTYセッションを作成し、セッションIDを返す */ ptyCreate: (source?: SessionSource): Promise => ipcRenderer.invoke('pty-create', source), - /** tmux / screen / zellij のセッション一覧を返す */ + /** マルチプレクサのセッション一覧を返す */ getMultiplexerSessions: (): Promise => ipcRenderer.invoke('get-multiplexer-sessions'), diff --git a/packages/desktop/src/renderer/components/SessionList.tsx b/packages/desktop/src/renderer/components/SessionList.tsx index 0b59ebe..2aa5ac3 100644 --- a/packages/desktop/src/renderer/components/SessionList.tsx +++ b/packages/desktop/src/renderer/components/SessionList.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' import type { SessionInfo, MultiplexerSessionInfo } from '@remocoder/shared' -import { sessionSourceIcon, sessionProjectName, formatSessionElapsed } from '@remocoder/shared' +import { MULTIPLEXER_KINDS, sessionSourceIcon, sessionProjectName, formatSessionElapsed } from '@remocoder/shared' interface SessionListProps { sessions: SessionInfo[] @@ -329,7 +329,7 @@ export function SessionList({ {!hasMux ? (

No sessions

-

No tmux / screen / zellij sessions found

+

No {MULTIPLEXER_KINDS.join(' / ')} sessions found

) : (
diff --git a/packages/mobile/src/assets/terminalHtml.ts b/packages/mobile/src/assets/terminalHtml.ts index 2e976bd..f5926aa 100644 --- a/packages/mobile/src/assets/terminalHtml.ts +++ b/packages/mobile/src/assets/terminalHtml.ts @@ -1,4 +1,4 @@ -import type { SessionSource } from '@remocoder/shared' +import { MULTIPLEXER_KINDS, type SessionSource } from '@remocoder/shared' /** * @param wsUrl WebSocket URL @@ -67,8 +67,7 @@ export function buildTerminalHtml( function getScrollTarget() { const bufType = term.buffer && term.buffer.active && term.buffer.active.type const src = currentSource || SESSION_SOURCE - const isMultiplexer = src && - (src.kind === 'tmux' || src.kind === 'screen' || src.kind === 'zellij') + const isMultiplexer = src && MULTIPLEXER_KINDS.includes(src.kind) return { bufType, isMultiplexer } } @@ -151,6 +150,7 @@ export function buildTerminalHtml( const ATTACH_SESSION_ID = ${sessionIdJs} // セッション起動元(null = projectPath を使用) const SESSION_SOURCE = ${sourceJs} + const MULTIPLEXER_KINDS = ${JSON.stringify(MULTIPLEXER_KINDS)} // 接続間でセッションIDを維持して再接続時に再アタッチする let currentSessionId = ATTACH_SESSION_ID diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 3ad3dc5..5cc610f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,14 +1,25 @@ +export const MULTIPLEXER_KINDS = ['tmux', 'screen', 'zellij', 'herdr'] as const +export type MultiplexerKind = (typeof MULTIPLEXER_KINDS)[number] + +export function isMultiplexerKind(kind: string): kind is MultiplexerKind { + return (MULTIPLEXER_KINDS as readonly string[]).includes(kind) +} + /** PTYセッションの起動元を表す型 */ export type SessionSource = | { kind: 'claude'; projectPath?: string } - | { kind: 'tmux'; sessionName: string } - | { kind: 'screen'; sessionName: string } - | { kind: 'zellij'; sessionName: string } + | { kind: MultiplexerKind; sessionName: string } | { kind: 'shell'; cwd?: string } -/** tmux / screen / zellij のセッション情報 */ +export type MultiplexerSource = Extract + +export function isMultiplexerSource(source: SessionSource): source is MultiplexerSource { + return isMultiplexerKind(source.kind) +} + +/** マルチプレクサのセッション情報 */ export interface MultiplexerSessionInfo { - tool: 'tmux' | 'screen' | 'zellij' + tool: MultiplexerKind sessionName: string /** セッションの追加情報(例: ウィンドウ数、状態) */ detail?: string @@ -110,7 +121,11 @@ export function sessionSourceIcon(source?: SessionSource): string { case 'tmux': return '📟' case 'screen': return '🖥' case 'zellij': return '🪟' - default: return '🖥' + case 'herdr': return '🐑' + default: { + const _exhaustive: never = source + return '🖥' + } } }