From c854f3f99052d29709d9b92b5bb3c6abadc5d781 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 07:43:18 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20herdr=20=E3=83=9E=E3=83=AB?= =?UTF-8?q?=E3=83=81=E3=83=97=E3=83=AC=E3=82=AF=E3=82=B5=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux / screen / zellij と同じ node-pty 経由のパターンで herdr を統合。 herdr session attach でアタッチし、herdr session list --json でセッション一覧を取得する。 Closes #129 Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/index.ts | 2 +- packages/desktop/src/main/pty-server.ts | 29 ++++++++++++++++--- packages/desktop/src/preload/index.ts | 2 +- .../src/renderer/components/SessionList.tsx | 2 +- packages/mobile/src/assets/terminalHtml.ts | 2 +- packages/shared/src/types.ts | 6 ++-- 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index ae9df55..4b07135 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 のセッション一覧を返す */ + /** tmux / screen / zellij / herdr のセッション一覧を返す */ 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..9b85046 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -517,7 +517,7 @@ 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') { + if (source.kind === 'tmux' || source.kind === 'screen' || source.kind === 'zellij' || source.kind === 'herdr') { const { kind, sessionName } = source const existing = Array.from(ptySessions.values()).find( (s) => s.source?.kind === kind && (s.source as typeof source).sessionName === sessionName, @@ -749,8 +749,8 @@ 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 allowedKinds = ['claude', 'tmux', 'screen', 'zellij', 'herdr', 'shell'] as const + const isMultiplexer = rawSource.kind === 'tmux' || rawSource.kind === 'screen' || rawSource.kind === 'zellij' || rawSource.kind === 'herdr' const isInvalid = !allowedKinds.includes(rawSource.kind as (typeof allowedKinds)[number]) || (isMultiplexer && (typeof rawSource.sessionName !== 'string' || rawSource.sessionName.length === 0)) @@ -1003,6 +1003,11 @@ function spawnSource(source: SessionSource): pty.IPty { console.log(`[pty-server] Attaching to zellij session: ${source.sessionName}`) return pty.spawn('zellij', ['attach', source.sessionName], baseOpts) } + case 'herdr': { + assertSafeSessionName(source.sessionName, 'herdr') + console.log(`[pty-server] Attaching to herdr session: ${source.sessionName}`) + return pty.spawn('herdr', ['session', 'attach', source.sessionName], baseOpts) + } case 'shell': { const loginShell = resolveShell() const cwd = source.cwd && existsSync(source.cwd) ? source.cwd : undefined @@ -1016,7 +1021,7 @@ function spawnSource(source: SessionSource): pty.IPty { // ─── マルチプレクサセッション一覧取得 ──────────────────────────────────────── -/** 利用可能な tmux / screen / zellij セッション一覧を取得する */ +/** 利用可能な tmux / screen / zellij / herdr セッション一覧を取得する */ export async function getMultiplexerSessions(): Promise { const results: MultiplexerSessionInfo[] = [] @@ -1080,5 +1085,21 @@ export async function getMultiplexerSessions(): Promise = JSON.parse(stdout) + 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', + }) + } + } catch { + // herdr未インストールまたはセッションなし + } + return results } diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 4a59e53..96f077b 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 のセッション一覧を返す */ + /** tmux / screen / zellij / herdr のセッション一覧を返す */ 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..85d8ea0 100644 --- a/packages/desktop/src/renderer/components/SessionList.tsx +++ b/packages/desktop/src/renderer/components/SessionList.tsx @@ -329,7 +329,7 @@ export function SessionList({ {!hasMux ? (

No sessions

-

No tmux / screen / zellij sessions found

+

No tmux / screen / zellij / herdr sessions found

) : (
diff --git a/packages/mobile/src/assets/terminalHtml.ts b/packages/mobile/src/assets/terminalHtml.ts index 2e976bd..43e6885 100644 --- a/packages/mobile/src/assets/terminalHtml.ts +++ b/packages/mobile/src/assets/terminalHtml.ts @@ -68,7 +68,7 @@ export function buildTerminalHtml( 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') + (src.kind === 'tmux' || src.kind === 'screen' || src.kind === 'zellij' || src.kind === 'herdr') return { bufType, isMultiplexer } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 3ad3dc5..c4c4fc7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,11 +4,12 @@ export type SessionSource = | { kind: 'tmux'; sessionName: string } | { kind: 'screen'; sessionName: string } | { kind: 'zellij'; sessionName: string } + | { kind: 'herdr'; sessionName: string } | { kind: 'shell'; cwd?: string } -/** tmux / screen / zellij のセッション情報 */ +/** tmux / screen / zellij / herdr のセッション情報 */ export interface MultiplexerSessionInfo { - tool: 'tmux' | 'screen' | 'zellij' + tool: 'tmux' | 'screen' | 'zellij' | 'herdr' sessionName: string /** セッションの追加情報(例: ウィンドウ数、状態) */ detail?: string @@ -110,6 +111,7 @@ export function sessionSourceIcon(source?: SessionSource): string { case 'tmux': return '📟' case 'screen': return '🖥' case 'zellij': return '🪟' + case 'herdr': return '🐑' default: return '🖥' } } From e4b4fef9ef386b4a60e8637c2ad69b5f90278e27 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 08:02:34 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20MULTIPLEXER=5FKINDS=20?= =?UTF-8?q?=E5=AE=9A=E6=95=B0=E3=81=A7=E9=87=8D=E8=A4=87=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=83=9E=E3=83=AB=E3=83=81=E3=83=97=E3=83=AC=E3=82=AF=E3=82=B5?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=82=92=E7=B5=B1=E5=90=88=E3=81=97=E3=80=81?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3=E4=B8=80=E8=A6=A7?= =?UTF-8?q?=E5=8F=96=E5=BE=97=E3=82=92=E4=B8=A6=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared に MULTIPLEXER_KINDS / isMultiplexerKind / isMultiplexerSource を導入 - pty-server.ts と terminalHtml.ts の ||チェーンを共有ヘルパーで置換 - getMultiplexerSessions() を Promise.allSettled() で並列実行に変更 Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 37 ++++++++-------------- packages/mobile/src/assets/terminalHtml.ts | 6 ++-- packages/shared/src/types.ts | 17 ++++++++-- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index 9b85046..da52878 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -4,7 +4,7 @@ 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, 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' @@ -517,7 +517,7 @@ 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' || source.kind === 'herdr') { + if (isMultiplexerSource(source)) { const { kind, sessionName } = source const existing = Array.from(ptySessions.values()).find( (s) => s.source?.kind === kind && (s.source as typeof source).sessionName === sessionName, @@ -749,11 +749,12 @@ 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', 'herdr', 'shell'] as const - const isMultiplexer = rawSource.kind === 'tmux' || rawSource.kind === 'screen' || rawSource.kind === 'zellij' || rawSource.kind === 'herdr' + const allowedKinds = ['claude', ...MULTIPLEXER_KINDS, 'shell'] as const + 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)) + (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)) @@ -1025,10 +1026,9 @@ function spawnSource(source: SessionSource): pty.IPty { export async function getMultiplexerSessions(): Promise { const results: MultiplexerSessionInfo[] = [] - // tmux - // TERM を明示的に設定することで tmux の vis(3) エンコードを抑制し、 - // タブ区切り出力が正しく得られるようにする(GUI 起動時は TERM が未設定になる) - try { + async function collectTmux(): Promise { + // 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' } }, @@ -1048,12 +1048,9 @@ export async function getMultiplexerSessions(): Promise { // screen -ls は接続中セッションがある場合に exit code 1 を返すため stdout を取り出す const screenOutput = await execAsync('screen -ls', { env: EXEC_ENV }).then( (r) => r.stdout, @@ -1067,26 +1064,19 @@ export async function getMultiplexerSessions(): Promise { 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未インストールまたはセッションなし } - // herdr - try { + async function collectHerdr(): Promise { const { stdout } = await execAsync('herdr session list --json', { env: EXEC_ENV }) const sessions: Array<{ name: string; running: boolean; session_dir?: string }> = JSON.parse(stdout) for (const s of sessions) { @@ -1097,9 +1087,8 @@ export async function getMultiplexerSessions(): Promise + +export function isMultiplexerSource(source: SessionSource): source is MultiplexerSource { + return isMultiplexerKind(source.kind) +} + +/** マルチプレクサのセッション情報 */ export interface MultiplexerSessionInfo { - tool: 'tmux' | 'screen' | 'zellij' | 'herdr' + tool: MultiplexerKind sessionName: string /** セッションの追加情報(例: ウィンドウ数、状態) */ detail?: string From 387bbe57bd9a0648c7e98614633042551b4765fc Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 08:22:53 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20allowedKinds=20=E3=82=92?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E5=AE=9A=E6=95=B0?= =?UTF-8?q?=E5=8C=96=E3=80=81=E3=82=A4=E3=83=B3=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=20Extract=20=E3=82=92=20MultiplexerSource=20=E3=81=AB=E7=B5=B1?= =?UTF-8?q?=E4=B8=80=E3=80=81SessionList=20=E3=81=AE=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E6=96=87=E5=AD=97=E5=88=97=E3=82=92=20MULTIPLEXER=5FKINDS=20?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=B0=8E=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 10 +++++----- .../desktop/src/renderer/components/SessionList.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index da52878..c0959b1 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -4,13 +4,14 @@ 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, MULTIPLEXER_KINDS, isMultiplexerKind, isMultiplexerSource } from '@remocoder/shared' +import { WsMessage, SessionInfo, ProjectInfo, MultiplexerSessionInfo, SessionSource, MultiplexerSource, 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 // ─── Claude プロジェクト一覧取得 ─────────────────────────────────────────────── @@ -749,11 +750,10 @@ export function startPtyServer(port = DEFAULT_WS_PORT, callbacks: PtyServerCallb detachFromSession() // source が指定されていればそれを使用、なければ後方互換で claude として扱う const rawSource = msg.source ?? { kind: 'claude', projectPath: msg.projectPath } - const allowedKinds = ['claude', ...MULTIPLEXER_KINDS, 'shell'] as const const isMultiplexer = isMultiplexerKind(rawSource.kind) const rawName = (rawSource as Record).sessionName const isInvalid = - !allowedKinds.includes(rawSource.kind as (typeof allowedKinds)[number]) || + !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)}`) @@ -766,8 +766,8 @@ export function startPtyServer(port = DEFAULT_WS_PORT, callbacks: PtyServerCallb const existingMux = isMultiplexer ? Array.from(ptySessions.values()).find( (s) => s.source?.kind === source.kind && - (s.source as Extract).sessionName === - (source as Extract).sessionName, + (s.source as MultiplexerSource).sessionName === + (source as MultiplexerSource).sessionName, ) : undefined const session = existingMux ?? createPtySession(source, clientIP) diff --git a/packages/desktop/src/renderer/components/SessionList.tsx b/packages/desktop/src/renderer/components/SessionList.tsx index 85d8ea0..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 / herdr sessions found

+

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

) : (
From 72138a7fdfee0cb4a3af194c8488dce9f9ddf200 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 18:26:32 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20findExistingMuxSession=20?= =?UTF-8?q?=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC=E6=8A=BD=E5=87=BA=E3=81=A7?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E6=8E=92=E9=99=A4=E3=80=81sessionSourceIcon?= =?UTF-8?q?=20=E3=81=AB=E7=B6=B2=E7=BE=85=E6=80=A7=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 21 +++++++++++---------- packages/shared/src/types.ts | 5 ++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index c0959b1..774c0b0 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -167,6 +167,14 @@ interface PtySession { /** 永続PTYセッションマップ(WS切断後も保持) */ const ptySessions = new Map() + +function findExistingMuxSession(source: MultiplexerSource): PtySession | undefined { + return Array.from(ptySessions.values()).find( + (s) => s.source?.kind === source.kind && + (s.source as MultiplexerSource).sessionName === source.sessionName, + ) +} + /** 認証済みかつ未アタッチのモバイル picker 接続セット */ const pickerSockets = new Set() @@ -519,12 +527,9 @@ function createExternalSession(providerWs: WebSocket): PtySession { export function desktopCreateSession(source: SessionSource = { kind: 'claude' }): string { // マルチプレクサの場合、同じセッションにアタッチ済みのPTYセッションがあれば再利用する if (isMultiplexerSource(source)) { - const { kind, sessionName } = source - const existing = Array.from(ptySessions.values()).find( - (s) => s.source?.kind === kind && (s.source as typeof source).sessionName === sessionName, - ) + 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 } } @@ -764,11 +769,7 @@ export function startPtyServer(port = DEFAULT_WS_PORT, callbacks: PtyServerCallb pickerSockets.delete(ws) // マルチプレクサは同名セッションが既存なら再利用する(Desktop と同じ挙動) const existingMux = isMultiplexer - ? Array.from(ptySessions.values()).find( - (s) => s.source?.kind === source.kind && - (s.source as MultiplexerSource).sessionName === - (source as MultiplexerSource).sessionName, - ) + ? findExistingMuxSession(source as MultiplexerSource) : undefined const session = existingMux ?? createPtySession(source, clientIP) // 既存セッションを再利用する場合は session_attach と同じ手順で安全にアタッチする diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b8fcc83..2e1a4ac 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -125,7 +125,10 @@ export function sessionSourceIcon(source?: SessionSource): string { case 'screen': return '🖥' case 'zellij': return '🪟' case 'herdr': return '🐑' - default: return '🖥' + default: { + const _exhaustive: never = source + return '🖥' + } } } From 3b8035a25734b78bfb6fdbdd942160b23e095f13 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 22:02:10 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20herdr=20session=20list=20--json=20?= =?UTF-8?q?=E3=81=AE=E5=BF=9C=E7=AD=94=E5=BD=A2=E5=BC=8F=20{sessions:[...]?= =?UTF-8?q?}=20=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index 774c0b0..2cd7f55 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -1079,7 +1079,9 @@ export async function getMultiplexerSessions(): Promise { const { stdout } = await execAsync('herdr session list --json', { env: EXEC_ENV }) - const sessions: Array<{ name: string; running: boolean; session_dir?: string }> = JSON.parse(stdout) + 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({ From 02e1f374934ebd96f5b60814491e5a4153fc9d54 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 22:05:46 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=E3=83=9E=E3=83=AB=E3=83=81?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=82=AF=E3=82=B5=E4=B8=80=E8=A6=A7=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E9=A0=86=E3=82=92=E5=AE=9A=E7=BE=A9=E9=A0=86?= =?UTF-8?q?=E3=81=AB=E5=9B=BA=E5=AE=9A=EF=BC=88=E4=B8=A6=E5=88=97=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E3=81=A7=E3=82=82=E6=B1=BA=E5=AE=9A=E7=9A=84=E3=81=AA?= =?UTF-8?q?=E9=A0=86=E5=BA=8F=E3=82=92=E4=BF=9D=E8=A8=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index 2cd7f55..55d59be 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -1025,9 +1025,8 @@ function spawnSource(source: SessionSource): pty.IPty { /** 利用可能な tmux / screen / zellij / herdr セッション一覧を取得する */ export async function getMultiplexerSessions(): Promise { - const results: MultiplexerSessionInfo[] = [] - - async function collectTmux(): Promise { + async function collectTmux(): Promise { + const results: MultiplexerSessionInfo[] = [] // TERM を明示的に設定することで tmux の vis(3) エンコードを抑制し、 // タブ区切り出力が正しく得られるようにする(GUI 起動時は TERM が未設定になる) const { stdout } = await execAsync( @@ -1049,9 +1048,11 @@ export async function getMultiplexerSessions(): Promise { + async function collectScreen(): Promise { + const results: MultiplexerSessionInfo[] = [] // screen -ls は接続中セッションがある場合に exit code 1 を返すため stdout を取り出す const screenOutput = await execAsync('screen -ls', { env: EXEC_ENV }).then( (r) => r.stdout, @@ -1065,9 +1066,11 @@ export async function getMultiplexerSessions(): Promise { + async function collectZellij(): Promise { + const results: MultiplexerSessionInfo[] = [] const { stdout } = await execAsync('zellij list-sessions', { env: EXEC_ENV }) for (const line of stdout.trim().split('\n').filter(Boolean)) { const sessionName = line.trim().split(/\s+/)[0] @@ -1075,9 +1078,11 @@ export async function getMultiplexerSessions(): Promise { + 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 }> = @@ -1090,8 +1095,9 @@ export async function getMultiplexerSessions(): Promise r.status === 'fulfilled' ? r.value : []) } From ab4cb9a2af4ee23eec8423304c8d08e3ee2c483e Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 22:12:25 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20findExistingMuxSession=20?= =?UTF-8?q?=E3=81=AE=E3=82=AD=E3=83=A3=E3=82=B9=E3=83=88=E3=82=92=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=97=E3=82=AC=E3=83=BC=E3=83=89=E3=81=AB=E7=BD=AE?= =?UTF-8?q?=E6=8F=9B=E3=80=81JSDoc=20=E3=82=92=E3=83=9E=E3=83=AB=E3=83=81?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=82=AF=E3=82=B5=E6=B1=8E=E7=94=A8=E8=A1=A8?= =?UTF-8?q?=E8=A8=98=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/index.ts | 2 +- packages/desktop/src/main/pty-server.ts | 7 ++++--- packages/desktop/src/preload/index.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 4b07135..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 / herdr のセッション一覧を返す */ + /** マルチプレクサのセッション一覧を返す */ 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 55d59be..509dc8d 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -170,8 +170,9 @@ const ptySessions = new Map() function findExistingMuxSession(source: MultiplexerSource): PtySession | undefined { return Array.from(ptySessions.values()).find( - (s) => s.source?.kind === source.kind && - (s.source as MultiplexerSource).sessionName === source.sessionName, + (s) => s.source && isMultiplexerSource(s.source) && + s.source.kind === source.kind && + s.source.sessionName === source.sessionName, ) } @@ -1023,7 +1024,7 @@ function spawnSource(source: SessionSource): pty.IPty { // ─── マルチプレクサセッション一覧取得 ──────────────────────────────────────── -/** 利用可能な tmux / screen / zellij / herdr セッション一覧を取得する */ +/** 利用可能なマルチプレクサセッション一覧を取得する */ export async function getMultiplexerSessions(): Promise { async function collectTmux(): Promise { const results: MultiplexerSessionInfo[] = [] diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 96f077b..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 / herdr のセッション一覧を返す */ + /** マルチプレクサのセッション一覧を返す */ getMultiplexerSessions: (): Promise => ipcRenderer.invoke('get-multiplexer-sessions'), From b694c6e647f435c4c2854a75ba6bfc48575b6bbe Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 22:28:31 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20session=5Fcreate=20=E3=81=AE?= =?UTF-8?q?=20existingMux=20=E6=A4=9C=E7=B4=A2=E3=81=A7=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=20isMultiplexerSource=20=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=97=E3=82=AC=E3=83=BC=E3=83=89=E3=81=AB=E7=B5=B1?= =?UTF-8?q?=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index 509dc8d..a59b9b2 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -769,8 +769,8 @@ export function startPtyServer(port = DEFAULT_WS_PORT, callbacks: PtyServerCallb const source = rawSource as SessionSource pickerSockets.delete(ws) // マルチプレクサは同名セッションが既存なら再利用する(Desktop と同じ挙動) - const existingMux = isMultiplexer - ? findExistingMuxSession(source as MultiplexerSource) + const existingMux = isMultiplexerSource(source) + ? findExistingMuxSession(source) : undefined const session = existingMux ?? createPtySession(source, clientIP) // 既存セッションを再利用する場合は session_attach と同じ手順で安全にアタッチする From a3385480b95021b3fc2b73af3e0662e0c254dd77 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 23:10:09 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20EXEC=5FENV=20=E3=81=AE=20PATH=20?= =?UTF-8?q?=E3=81=AB=20~/.local/bin=20=E3=81=A8=20~/.cargo/bin=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=EF=BC=88GUI=20=E8=B5=B7=E5=8B=95=E6=99=82?= =?UTF-8?q?=E3=81=AE=20herdr=20=E6=A4=9C=E5=87=BA=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/exec-env.ts | 5 +++++ 1 file changed, 5 insertions(+) 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', From c9ab50bb85b5f2f9ea637ea14577f1bf4b5e80ed Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Thu, 9 Jul 2026 23:22:40 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20spawnSource=20=E3=81=AE=20env=20?= =?UTF-8?q?=E3=82=92=20EXEC=5FENV=20=E3=81=AB=E7=B5=B1=E4=B8=80=EF=BC=88GU?= =?UTF-8?q?I=20=E8=B5=B7=E5=8B=95=E6=99=82=E3=81=AE=E3=83=9E=E3=83=AB?= =?UTF-8?q?=E3=83=81=E3=83=97=E3=83=AC=E3=82=AF=E3=82=B5=E6=A4=9C=E5=87=BA?= =?UTF-8?q?=E3=81=A8=E4=B8=80=E8=87=B4=E3=81=95=E3=81=9B=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index a59b9b2..89a6460 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -983,7 +983,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() From 2a34ff26f3207a53a04cbaa5762b26f3027a1d62 Mon Sep 17 00:00:00 2001 From: Shun Okada Date: Fri, 10 Jul 2026 07:28:19 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20spawnSource=20=E3=81=AE?= =?UTF-8?q?=E3=83=9E=E3=83=AB=E3=83=81=E3=83=97=E3=83=AC=E3=82=AF=E3=82=B5?= =?UTF-8?q?=E5=88=86=E5=B2=90=E3=82=92=20MUX=5FSPAWN=5FCONFIG=20=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=83=96=E3=83=AB=E9=A7=86=E5=8B=95=E3=81=AB=E7=B5=B1?= =?UTF-8?q?=E5=90=88=E3=80=81SessionSource=20=E5=9E=8B=E3=82=92=20Multiple?= =?UTF-8?q?xerKind=20=E3=81=A7=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/desktop/src/main/pty-server.ts | 57 ++++++++++++++----------- packages/shared/src/types.ts | 5 +-- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/desktop/src/main/pty-server.ts b/packages/desktop/src/main/pty-server.ts index 89a6460..22697ce 100644 --- a/packages/desktop/src/main/pty-server.ts +++ b/packages/desktop/src/main/pty-server.ts @@ -4,7 +4,7 @@ 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, MultiplexerSource, DEFAULT_WS_PORT, MULTIPLEXER_KINDS, isMultiplexerKind, isMultiplexerSource } 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' @@ -13,6 +13,13 @@ 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 プロジェクト一覧取得 ─────────────────────────────────────────────── /** @@ -169,11 +176,14 @@ interface PtySession { const ptySessions = new Map() function findExistingMuxSession(source: MultiplexerSource): PtySession | undefined { - return Array.from(ptySessions.values()).find( - (s) => s.source && isMultiplexerSource(s.source) && + for (const s of ptySessions.values()) { + if (s.source && isMultiplexerSource(s.source) && s.source.kind === source.kind && - s.source.sessionName === source.sessionName, - ) + s.source.sessionName === source.sessionName) { + return s + } + } + return undefined } /** 認証済みかつ未アタッチのモバイル picker 接続セット */ @@ -991,25 +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, 'herdr') - console.log(`[pty-server] Attaching to herdr session: ${source.sessionName}`) - return pty.spawn('herdr', ['session', 'attach', source.sessionName], baseOpts) + 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() @@ -1017,8 +1016,10 @@ 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}`) + } } } @@ -1099,6 +1100,12 @@ export async function getMultiplexerSessions(): Promise 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/shared/src/types.ts b/packages/shared/src/types.ts index 2e1a4ac..5cc610f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -8,10 +8,7 @@ export function isMultiplexerKind(kind: string): kind is MultiplexerKind { /** PTYセッションの起動元を表す型 */ export type SessionSource = | { kind: 'claude'; projectPath?: string } - | { kind: 'tmux'; sessionName: string } - | { kind: 'screen'; sessionName: string } - | { kind: 'zellij'; sessionName: string } - | { kind: 'herdr'; sessionName: string } + | { kind: MultiplexerKind; sessionName: string } | { kind: 'shell'; cwd?: string } export type MultiplexerSource = Extract