From 676c8771cbad6bb6f38fc6752d884200ef8abfa8 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:48:50 +0900 Subject: [PATCH 01/26] =?UTF-8?q?fix(server):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EC=84=B8=EC=85=98=20=EA=B0=90=EC=A7=80=20macOS=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=20=E2=80=94=20/proc=C2=B7lsof=20-c=20gjc=20?= =?UTF-8?q?=EB=A6=AC=EB=88=85=EC=8A=A4=20=EA=B0=80=EC=A0=95=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gjc 라이브 레인이 macOS에서 항상 0건이었다: - lsof -c gjc: comm 매칭이라 bun 런타임으로 도는 gjc(comm=bun)를 못 찾음 → -c gjc -c bun -c node로 확장 (SESSION_FILE_RE가 실질 필터, -c는 비용 절감용) - /proc//stat, /proc//cwd: macOS에 /proc 없음 → 조상 체인은 ps -eo pid=,ppid= 스냅샷 1회, holder cwd는 배치 lsof -d cwd로 대체 (둘 다 Linux에서도 동일 동작 — 플랫폼 분기 없음) 외부 CLI 레인: macOS ps -eo comm은 실행파일 풀 경로를 찍어 claude/codex descendant 분류가 전부 미스 → comm basename 정규화. 검증: 유닛 30/30, tsc 통과, 실기(darwin) getLiveGjcSessions() 5건 검출 (lineage/cwd/null 클레임 분류 정상). --- .../services/external-cli-sessions.service.ts | 16 +++- .../services/live-sessions.service.ts | 90 ++++++++++++++----- .../external-cli-sessions.service.test.ts | 23 +++++ .../tests/live-sessions.service.test.ts | 36 ++++++++ 4 files changed, 138 insertions(+), 27 deletions(-) diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index d70d8025fb..6487662c54 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -25,6 +25,16 @@ export type ExternalCliSession = { tmuxName: string; kind: ExternalCliKind }; /** Matches the tower/live-send tmux-name discipline; also safe to embed in a shell command. */ export const EXTERNAL_TMUX_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/; +/** + * macOS `ps -eo comm` prints the executable's FULL PATH (e.g. + * /Applications/ChatGPT.app/Contents/Resources/codex) while Linux prints the + * bare name — classification compares bare names, so normalize to basename. + */ +function commBasename(comm: string): string { + const slash = comm.lastIndexOf('/'); + return slash < 0 ? comm : comm.slice(slash + 1); +} + /** Parses `#{session_name}\t#{pane_pid}\t#{pane_current_command}` lines. */ export function parseExternalPanes(output: string): Array<{ name: string; pid: number; command: string }> { const panes: Array<{ name: string; pid: number; command: string }> = []; @@ -39,7 +49,7 @@ export function parseExternalPanes(output: string): Array<{ name: string; pid: n } const name = raw.slice(0, first).trim(); const pid = Number.parseInt(raw.slice(first + 1, second).trim(), 10); - const command = raw.slice(second + 1).trim(); + const command = commBasename(raw.slice(second + 1).trim()); if (name && Number.isFinite(pid)) { panes.push({ name, pid, command }); } @@ -47,7 +57,7 @@ export function parseExternalPanes(output: string): Array<{ name: string; pid: n return panes; } -/** Parses `ps -eo pid,ppid,comm` output into {pid, ppid, comm} rows (header tolerated). */ +/** Parses `ps -eo pid,ppid,comm` into {pid, ppid, comm} rows (header tolerated, comm → basename). */ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; comm: string }> { const rows: Array<{ pid: number; ppid: number; comm: string }> = []; for (const raw of output.split(/\r?\n/)) { @@ -59,7 +69,7 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; if (!match) { continue; // header or malformed line } - rows.push({ pid: Number.parseInt(match[1], 10), ppid: Number.parseInt(match[2], 10), comm: match[3].trim() }); + rows.push({ pid: Number.parseInt(match[1], 10), ppid: Number.parseInt(match[2], 10), comm: commBasename(match[3].trim()) }); } return rows; } diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index 93c0db656d..b0efb606b4 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { open, readFile, realpath, stat } from 'node:fs/promises'; +import { open, realpath, stat } from 'node:fs/promises'; /** * Live gjc session detection + tmux-session naming. @@ -7,14 +7,17 @@ import { open, readFile, realpath, stat } from 'node:fs/promises'; * A gjc session is "live" when a running gjc process has its transcript file open. * For the "작동 중" fleet view we also map each live session id → the tmux session * NAME it runs in (omg / stock / flask / …), by PROCESS LINEAGE: - * - lsof (-c gjc -F pn) → {session-id uuid, holder pid} for open session files - * - /proc//stat → the holder's ancestor pid chain + * - lsof (-c gjc/bun/node -F pn) → {session-id uuid, holder pid} for open session + * files (macOS: gjc runs under its runtime wrapper, so comm is `bun`/`node` — + * `-c gjc` alone finds nothing there; the session-file path is the real filter) + * - ps -eo pid=,ppid= → one snapshot for the holder's ancestor pid chain + * (portable: macOS has no /proc) * - tmux list-panes → {session_name, pane_pid, pane cwd (realpath)} * - a pane_pid found in the holder's ancestor chain → that pane's tmux name (0 ambiguity) * - cwd equality is a FALLBACK only (many-to-many when panes share a cwd) * * Matching is PATH-AGNOSTIC (uuid + realpath'd cwds), so production cloudcli's - * decoy HOME (whose `.gjc` is a symlink) does not break it. tmux/lsof/proc access + * decoy HOME (whose `.gjc` is a symlink) does not break it. tmux/lsof/ps access * is ISOLATED here and fails closed to [] (or tmuxName:null on a miss — the UI * falls back to the conversation title). */ @@ -196,32 +199,27 @@ async function safeRealpath(target: string): Promise { } } -/** Reads the parent pid from /proc//stat (comm may contain spaces/parens). */ -async function readParentPid(pid: number): Promise { - try { - const content = await readFile(`/proc/${pid}/stat`, 'utf8'); - const rparen = content.lastIndexOf(')'); - if (rparen < 0) { - return null; +/** Parses `ps -eo pid=,ppid=` output into a child pid → parent pid map. */ +export function parsePidParents(output: string): Map { + const parents = new Map(); + for (const raw of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(raw); + if (match) { + parents.set(Number.parseInt(match[1], 10), Number.parseInt(match[2], 10)); } - // After "pid (comm)" the fields are: state ppid pgrp … → index 1 is ppid. - const fields = content.slice(rparen + 2).trim().split(/\s+/); - const ppid = Number.parseInt(fields[1] ?? '', 10); - return Number.isFinite(ppid) ? ppid : null; - } catch { - return null; } + return parents; } /** Walks the ancestor pid chain [pid, ppid, …] toward init (depth/cycle guarded). */ -async function buildPidChain(pid: number): Promise { +export function buildPidChain(pid: number, parents: ReadonlyMap): number[] { const chain: number[] = []; const seen = new Set(); let cur = pid; for (let i = 0; i < 64 && cur > 1 && !seen.has(cur); i += 1) { chain.push(cur); seen.add(cur); - const parent = await readParentPid(cur); + const parent = parents.get(cur); if (parent == null) { break; } @@ -230,6 +228,21 @@ async function buildPidChain(pid: number): Promise { return chain; } +/** Maps pid → cwd from `lsof -a -p -d cwd -F pn` output (first path wins). */ +export function parseCwdByPidFromLsof(output: string): Map { + const cwds = new Map(); + let pid: number | null = null; + for (const raw of output.split(/\r?\n/)) { + if (raw.startsWith('p')) { + const parsed = Number.parseInt(raw.slice(1), 10); + pid = Number.isFinite(parsed) ? parsed : null; + } else if (raw.startsWith('n') && pid != null && !cwds.has(pid)) { + cwds.set(pid, raw.slice(1)); + } + } + return cwds; +} + /** Maps session id → transcript path from lsof `n` lines (first path wins). */ export function extractSessionPathsFromLsof(output: string): Map { const paths = new Map(); @@ -334,7 +347,7 @@ async function readLastModelFromFile(path: string): Promise { /** * Returns live gjc sessions with their tmux session name. Empty on any failure - * (no tmux/lsof, spawn error) — tmux/lsof/proc dependence is confined here. + * (no tmux/lsof, spawn error) — tmux/lsof/ps dependence is confined here. */ export async function getLiveGjcSessions(): Promise { let tmuxOutput: string; @@ -353,16 +366,45 @@ export async function getLiveGjcSessions(): Promise { let lsofOutput: string; try { - lsofOutput = await runCommand('lsof', ['-c', 'gjc', '-F', 'pn']); + // -c matches the process COMM: a Linux gjc binary is `gjc`, but a script + // install runs under its runtime (macOS 실측: comm은 `bun`) — cover both. + // SESSION_FILE_RE below is the authoritative filter; -c only bounds cost. + lsofOutput = await runCommand('lsof', ['-c', 'gjc', '-c', 'bun', '-c', 'node', '-F', 'pn']); } catch { return []; } + const holders = parseLsofPidSessions(lsofOutput); + + // One ps snapshot for ancestor chains — /proc//stat does not exist on + // macOS. Best-effort: an empty map only disables lineage, cwd fallback stays. + let parents: Map = new Map(); + try { + parents = parsePidParents(await runCommand('ps', ['-eo', 'pid=,ppid='])); + } catch { + // fall through with an empty map + } + + // Holder cwds for the label-only fallback — /proc//cwd does not exist on + // macOS; one batched lsof -d cwd works on both platforms. Best-effort too. + let cwdByPid = new Map(); + const holderPids = [...new Set(holders.map((holder) => holder.pid))]; + if (holderPids.length > 0) { + try { + cwdByPid = parseCwdByPidFromLsof( + await runCommand('lsof', ['-a', '-p', holderPids.join(','), '-d', 'cwd', '-F', 'pn']), + ); + } catch { + // fall through with an empty map + } + } + const sessions: Array<{ id: string; pidChain: number[]; cwd: string | null }> = []; - for (const { id, pid } of parseLsofPidSessions(lsofOutput)) { + for (const { id, pid } of holders) { + const rawCwd = cwdByPid.get(pid); sessions.push({ id, - pidChain: await buildPidChain(pid), - cwd: await safeRealpath(`/proc/${pid}/cwd`), + pidChain: buildPidChain(pid, parents), + cwd: rawCwd ? await safeRealpath(rawCwd) : null, }); } diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index 8a18420e9b..77922455ac 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -25,6 +25,29 @@ test('parsePsTree parses pid,ppid,comm rows and tolerates the header', () => { ]); }); +test('parsePsTree normalizes macOS full-path comm to basename (실측 shape)', () => { + // macOS `ps -eo comm` prints executable paths (may contain spaces); Linux prints bare names. + const out = parsePsTree([ + ' PID PPID COMM', + '21852 21706 /Applications/ChatGPT.app/Contents/Resources/codex', + '21995 21706 /Users/dev/.codex/computer-use/Codex Computer Use.app/Contents/MacOS/SkyComputerUseService', + '89726 89725 bun', + ].join('\n')); + assert.deepEqual(out, [ + { pid: 21852, ppid: 21706, comm: 'codex' }, + { pid: 21995, ppid: 21706, comm: 'SkyComputerUseService' }, + { pid: 89726, ppid: 89725, comm: 'bun' }, + ]); +}); + +test('classifyExternalSessions: macOS full-path codex descendant still classifies via basename', () => { + const result = classifyExternalSessions({ + panes: parseExternalPanes('gpt\t21706\tzsh\n'), + procs: parsePsTree('21706 1 zsh\n21852 21706 /Applications/ChatGPT.app/Contents/Resources/codex\n'), + }); + assert.deepEqual(result, [{ tmuxName: 'gpt', kind: 'codex' }]); +}); + test('classifyExternalSessions: claude pane by pane_current_command (실측 shape)', () => { const result = classifyExternalSessions({ panes: [{ name: 'patina', pid: 113501, command: 'claude' }], diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index bf86572b44..3e07627b20 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -2,10 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildPidChain, computeLiveSessions, extractSessionPathsFromLsof, + parseCwdByPidFromLsof, parseLastModelChange, parseLsofPidSessions, + parsePidParents, parseTmuxPanes, tmuxHasPanes, } from '@/modules/providers/services/live-sessions.service.js'; @@ -39,6 +42,39 @@ test('parseLsofPidSessions pairs uuid with holder pid, path-agnostic (decoy-HOME ]); }); +test('parsePidParents parses headerless `ps -eo pid=,ppid=` output (BSD right-aligned padding)', () => { + // macOS(BSD) ps pads columns with leading spaces; Linux(procps) output parses identically. + const parents = parsePidParents(' 1 0\n89726 89725\n93770 93769\n\nnot a row\n'); + assert.deepEqual([...parents], [[1, 0], [89726, 89725], [93770, 93769]]); +}); + +test('buildPidChain walks [pid, ppid, …] toward init from a ps snapshot', () => { + // 실측 macOS shape: bun(gjc) → zsh -c wrapper → tmux pane pid. + const parents = new Map([[93770, 93769], [93769, 93768], [93768, 1]]); + assert.deepEqual(buildPidChain(93770, parents), [93770, 93769, 93768]); + // unknown pid: chain is just the pid itself (lineage miss, not a crash) + assert.deepEqual(buildPidChain(555, new Map()), [555]); +}); + +test('buildPidChain is cycle-guarded (corrupt/racing ps snapshot cannot loop)', () => { + const parents = new Map([[10, 20], [20, 10]]); + assert.deepEqual(buildPidChain(10, parents), [10, 20]); +}); + +test('parseCwdByPidFromLsof maps pid → cwd (first path wins, spaces preserved)', () => { + const lsof = [ + 'p31394', + 'n/Volumes/Data/Dev Workspace/lazy-dev-cli', + 'p89726', + 'n/tmp', + 'nphantom-second-path', + ].join('\n'); + assert.deepEqual([...parseCwdByPidFromLsof(lsof)], [ + [31394, '/Volumes/Data/Dev Workspace/lazy-dev-cli'], + [89726, '/tmp'], + ]); +}); + test('computeLiveSessions maps each live session to its tmux name by pid lineage', () => { const result = computeLiveSessions({ tmuxPresent: true, From dcf5cace11dafaaf8399f4372dc72dd6b4cb6915 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:52:37 +0900 Subject: [PATCH 02/26] =?UTF-8?q?fix(server):=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A7=A4=EB=8B=88=EC=A0=80(launchd)=20=EB=AC=B4?= =?UTF-8?q?=EB=A1=9C=EC=BC=80=EC=9D=BC=EC=97=90=EC=84=9C=20tmux=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=83=88=EB=8B=88=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchd/systemd는 로케일 env를 안 준다. 비UTF-8 로케일의 tmux는 출력을 새니타이즈해 \t 필드 구분자가 '_'로 치환되고 비ASCII 경로가 이스케이프됨 → parseTmuxPanes가 전 행 파싱 실패 → 모든 라이브 세션 tmuxName:null (실측: launchd 서버에서 UI 전 세션 열람 전용으로 강등, 전송 불가). runCommand spawn env에 LANG UTF-8 강제 (양 서비스 동일 적용). 검증: env -i(launchd 재현)에서 lineage/cwd 클레임 정상 복원, 유닛 30/30, tsc 통과. --- .../services/external-cli-sessions.service.ts | 8 +++++++- .../providers/services/live-sessions.service.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index 6487662c54..7ecdb42fb1 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -159,7 +159,13 @@ export function classifyExternalSessions(args: { function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, cmdArgs, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + const child = spawn(command, cmdArgs, { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // Service managers ship no locale; non-UTF-8 tmux output sanitizes \t + // separators to `_` (see live-sessions.service.ts). Force UTF-8. + env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' }, + }); let stdout = ''; let settled = false; const timer = setTimeout(() => { diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index b0efb606b4..24a13cb0de 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -171,7 +171,15 @@ export function computeLiveSessions(args: { function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, cmdArgs, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + const child = spawn(command, cmdArgs, { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // Service managers (launchd/systemd) ship no locale. In a non-UTF-8 + // locale tmux SANITIZES its output — the \t field separators come back + // as `_` and non-ASCII paths get escaped — which silently unparses every + // pane row (실측 macOS launchd: 모든 세션 tmuxName null). Force UTF-8. + env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' }, + }); let stdout = ''; let settled = false; const timer = setTimeout(() => { From b0fb860f74b201559e0ddaf6c4ff94f0e221ff41 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:19:43 +0900 Subject: [PATCH 03/26] =?UTF-8?q?fix(server):=20idle=20=EB=A0=88=EC=9D=B8?= =?UTF-8?q?=20gjc=20=ED=8C=90=EC=A0=95=20macOS=20=ED=98=B8=ED=99=98=20?= =?UTF-8?q?=E2=80=94=20argv=20=EA=B8=B0=EB=B0=98=20gjc=20pid=20=EC=A6=9D?= =?UTF-8?q?=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit idle 레인(fix/live-lane-idle-gjc-sessions)의 핵심 신호가 ps comm === 'gjc'인데, macOS script install은 gjc가 런타임(comm=bun)으로 떠서 idle 레인 전체가 무력했다 (실측: 스폰 직후 세션이 목록에 영영 안 뜸). - parseGjcPidsFromPsArgs 신설: ps -eo pid=,args= 의 argv에서 gjc 실행 파일 증거 (argv0 basename 'gjc', 또는 '/' 포함 토큰의 basename 'gjc'/'gjc.js') → pid 집합. bare 'gjc' 단어(grep gjc 등)는 증거로 안 침. 셸 래퍼 꼬리 구두점(;) 정규화. - findIdleGjcTmuxSessions·classifyExternalSessions에 gjcPids 주입 — comm 계약은 유지하고 argv 증거는 합성 'gjc' comm으로 흡수. 검증: providers 유닛 104/107(실패 3건은 본 브랜치 기존 gjc-synchronizer 테스트 격리 누수 — 실HOME 세션 스캔, 4!==1 — 본 변경과 무관), notify 7/7, tsc·eslint 통과. 실기(darwin) idle 행 3건 검출(스폰 직후 세션 포함), launchd 재현 env 동일. --- .../services/external-cli-sessions.service.ts | 49 +++++++++++++++++++ .../services/live-sessions.service.ts | 14 ++++-- .../external-cli-sessions.service.test.ts | 30 ++++++++++++ .../tests/live-sessions.service.test.ts | 17 +++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index 2e169f5580..fb9e1cff12 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -74,6 +74,40 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; return rows; } +/** + * Pids whose COMMAND LINE identifies gjc, from `ps -eo pid=,args=` output. + * comm alone cannot see script installs — macOS 실측: gjc runs as + * `bun /…/.bun/bin/gjc`, so comm is `bun`. Argv is the portable evidence: + * a pid counts when argv[0]'s basename is 'gjc', or any PATH-looking token + * ('/' 포함) has basename 'gjc'/'gjc.js' (covers `bun /…/gjc`, + * `node /…/gjc.js`, wrapper scripts). Bare non-argv0 'gjc' words + * (e.g. `grep gjc`) are deliberately NOT evidence. + */ +export function parseGjcPidsFromPsArgs(output: string): Set { + const pids = new Set(); + for (const raw of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(.+)$/.exec(raw); + if (!match) { + continue; + } + const tokens = match[2].trim().split(/\s+/); + const isGjc = tokens.some((rawToken, index) => { + // Shell wrapper argv flattens `sh -c "…; /path/gjc; rc=$?"` into tokens + // like `/path/gjc;` — strip trailing shell punctuation before matching. + const token = rawToken.replace(/[;,)&|]+$/, ''); + const base = token.slice(token.lastIndexOf('/') + 1); + if (index === 0) { + return base === 'gjc'; + } + return token.includes('/') && (base === 'gjc' || base === 'gjc.js'); + }); + if (isGjc) { + pids.add(Number.parseInt(match[1], 10)); + } + } + return pids; +} + /** * Pure classification: tmux panes + a ps snapshot → external CLI sessions. * @@ -89,6 +123,8 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; export function classifyExternalSessions(args: { panes: Array<{ name: string; pid: number; command: string }>; procs: Array<{ pid: number; ppid: number; comm: string }>; + /** Pids proven gjc by argv (script installs whose comm is the runtime). */ + gjcPids?: ReadonlySet; }): ExternalCliSession[] { const children = new Map(); for (const proc of args.procs) { @@ -99,6 +135,13 @@ export function classifyExternalSessions(args: { children.set(proc.ppid, [proc.pid]); } } + + /** + * gjc evidence beyond comm: script installs run under their runtime (macOS + * 실측: comm은 `bun`), so pids proven gjc by ARGV (parseGjcPidsFromPsArgs) + * inject a synthetic 'gjc' comm — the exclusion contract stays comm-based. + */ + const gjcPids = args.gjcPids ?? new Set(); const commByPid = new Map(); for (const proc of args.procs) { commByPid.set(proc.pid, proc.comm); @@ -118,6 +161,9 @@ export function classifyExternalSessions(args: { if (comm) { comms.add(comm); } + if (gjcPids.has(pid)) { + comms.add('gjc'); + } for (const child of children.get(pid) ?? []) { queue.push(child); } @@ -199,14 +245,17 @@ function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promi export async function getExternalCliSessions(): Promise { let tmuxOutput: string; let psOutput: string; + let psArgsOutput: string; try { tmuxOutput = await runCommand('tmux', ['list-panes', '-a', '-F', `#{session_name}${TMUX_FIELD_SEP}#{pane_pid}${TMUX_FIELD_SEP}#{pane_current_command}`]); psOutput = await runCommand('ps', ['-eo', 'pid,ppid,comm']); + psArgsOutput = await runCommand('ps', ['-eo', 'pid=,args=']); } catch { return []; } return classifyExternalSessions({ panes: parseExternalPanes(tmuxOutput), procs: parsePsTree(psOutput), + gjcPids: parseGjcPidsFromPsArgs(psArgsOutput), }); } diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index 818502a9dc..303fe3883e 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import { open, realpath, stat } from 'node:fs/promises'; -import { parsePsTree } from './external-cli-sessions.service.js'; +import { parseGjcPidsFromPsArgs, parsePsTree } from './external-cli-sessions.service.js'; /** * Live gjc session detection + tmux-session naming. @@ -73,6 +73,8 @@ const IDLE_TMUX_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export function findIdleGjcTmuxSessions(args: { panes: Array<{ name: string; sid: string; pid: number }>; procs: Array<{ pid: number; ppid: number; comm: string }>; + /** Pids proven gjc by argv — macOS 실측: script installs' comm is `bun`. */ + gjcPids?: ReadonlySet; excludedNames: ReadonlySet; }): Array<{ name: string; sid: string }> { const children = new Map(); @@ -87,6 +89,7 @@ export function findIdleGjcTmuxSessions(args: { commByPid.set(proc.pid, proc.comm); } + const gjcPids = args.gjcPids ?? new Set(); const subtreeHasGjc = (rootPid: number): boolean => { const seen = new Set(); const queue: number[] = [rootPid]; @@ -96,7 +99,7 @@ export function findIdleGjcTmuxSessions(args: { continue; } seen.add(pid); - if (commByPid.get(pid) === 'gjc') { + if (commByPid.get(pid) === 'gjc' || gjcPids.has(pid)) { return true; } for (const child of children.get(pid) ?? []) { @@ -566,10 +569,15 @@ async function scanLiveGjcSessions(): Promise { // is LINEAGE names only — a cwd label must not hide a subtree-proven pane. let idlePanes: Array<{ name: string; sid: string }> = []; try { - const psOutput = await runCommand('ps', ['-eo', 'pid,ppid,comm']); + const [psOutput, psArgsOutput] = await Promise.all([ + runCommand('ps', ['-eo', 'pid,ppid,comm']), + // argv snapshot: comm cannot see script installs (macOS: gjc → `bun`). + runCommand('ps', ['-eo', 'pid=,args=']), + ]); idlePanes = findIdleGjcTmuxSessions({ panes, procs: parsePsTree(psOutput), + gjcPids: parseGjcPidsFromPsArgs(psArgsOutput), excludedNames: new Set( named.flatMap((session) => (session.claim === 'lineage' && session.tmuxName ? [session.tmuxName] : [])), ), diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index a21f81e5a3..d21ac9550e 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -5,6 +5,7 @@ import { EXTERNAL_TMUX_NAME_RE, classifyExternalSessions, parseExternalPanes, + parseGjcPidsFromPsArgs, parsePsTree, } from '@/modules/providers/services/external-cli-sessions.service.js'; @@ -165,3 +166,32 @@ test('classifyExternalSessions: sorted by tmux name for stable rendering', () => }); assert.deepEqual(result.map((s) => s.tmuxName), ['alpha', 'zeta']); }); + +test('parseGjcPidsFromPsArgs: argv 증거로 gjc pid 식별 (macOS 실측 shapes)', () => { + const pids = parseGjcPidsFromPsArgs([ + '89726 bun /Users/dev/.bun/bin/gjc', // macOS script install + ' 100 gjc --no-session', // Linux native binary (argv0) + ' 200 node /opt/gjc/bin/gjc.js notify daemon-internal', // node runtime + ' 300 grep gjc server.log', // bare word — NOT evidence + ' 400 vim gjc-notes.md', // 유사 이름 — NOT evidence + ' 500 zsh -c export PATH=…; /Users/dev/.bun/bin/gjc; rc=$?', // launcher wrapper + ].join('\n')); + assert.deepEqual([...pids].sort((a, b) => a - b), [89726, 100, 200, 500].sort((a, b) => a - b)); +}); + +test('classifyExternalSessions: bun으로 도는 gjc도 gjcPids로 제외 (macOS live lane contract)', () => { + const result = classifyExternalSessions({ + panes: [ + { name: 'mixed', pid: 1000, command: 'zsh' }, + { name: 'pure-claude', pid: 2000, command: 'claude' }, + ], + procs: [ + { pid: 1000, ppid: 1, comm: 'zsh' }, + { pid: 1001, ppid: 1000, comm: 'bun' }, // gjc via bun — comm으로는 안 보임 + { pid: 1002, ppid: 1000, comm: 'claude' }, // 같은 세션에 claude 공존 + { pid: 2000, ppid: 1, comm: 'claude' }, + ], + gjcPids: new Set([1001]), + }); + assert.deepEqual(result, [{ tmuxName: 'pure-claude', kind: 'claude' }]); +}); diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index b788287c77..dff593bf12 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -327,3 +327,20 @@ test('IDLE_GJC_ID_PREFIX cannot collide with transcript uuids (client contract)' assert.equal(IDLE_GJC_ID_PREFIX, 'idle-gjc:'); assert.ok(!/^[0-9a-fA-F-]+$/.test(IDLE_GJC_ID_PREFIX)); }); + +test('findIdleGjcTmuxSessions: bun-wrapped gjc pane은 gjcPids 증거로 idle 행이 된다 (macOS 실측)', () => { + const result = findIdleGjcTmuxSessions({ + panes: [ + { name: 'test', sid: '$7', pid: 27614 }, + { name: 'plain-shell', sid: '$8', pid: 30000 }, + ], + procs: [ + { pid: 27614, ppid: 1, comm: 'sh' }, + { pid: 27615, ppid: 27614, comm: 'bun' }, // gjc via bun — comm 'gjc' 절대 안 됨 + { pid: 30000, ppid: 1, comm: 'zsh' }, + ], + gjcPids: new Set([27615]), + excludedNames: new Set(), + }); + assert.deepEqual(result, [{ name: 'test', sid: '$7' }]); +}); From 904c4ead61e29dafbcfe8936adcb9923410c55ce Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:29:39 +0900 Subject: [PATCH 04/26] chore: drop accidentally committed node_modules symlink --- node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index 09b740a3b8..0000000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -../claudecodeui/node_modules \ No newline at end of file From ef9fe8f22ddf914aaaef3ca1dea01b8f09164535 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:38:04 +0900 Subject: [PATCH 05/26] =?UTF-8?q?feat(sidebar):=20idle=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=ED=96=89=EC=97=90=EC=84=9C=20=EC=9B=B9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EB=A1=9C=20=EC=B2=AB=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit idle-gjc 행은 클릭 불가라 첫 메시지를 보내려면 tmux로 들어가야 했다 — 웹/모바일 전용 사용에선 스폰 후 막다른 길. lineage 등급 idle 행에 인라인 컴포저를 붙여 기존 /sessions/live/send 릴레이(관제탑 경유, 서버측 lineage 게이트 동일)로 첫 메시지를 보낸다. 전송되면 gjc가 transcript를 열고 5초 폴이 실제 대화 행으로 전환한다. 비-lineage 행에는 절대 노출 안 됨(patina 계약). 테스트 5/5. --- .../subcomponents/SidebarLiveSection.test.tsx | 19 +++ .../view/subcomponents/SidebarLiveSection.tsx | 109 +++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx index ce09032dba..a82312e66e 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx @@ -89,4 +89,23 @@ test('SidebarLiveSection renders idle-gjc rows as 대기 (첫 대화 전 gjc pan assert.ok(!html.includes('LIVE'), 'no LIVE badge for a session with no transcript'); assert.ok(html.includes('아직 대화가 없습니다'), 'explains why it is not openable yet'); assert.ok(html.includes('tmux 세션 flask 닫기'), 'lineage-grade idle rows keep the kill control'); + assert.ok(html.includes('첫 메시지 보내기'), 'lineage-grade idle rows offer the inline first-message composer'); +}); + +test('SidebarLiveSection: non-lineage rows never get the first-message composer', () => { + // A tmuxName without lineage proof must not receive keystrokes (patina 실사고 + // 계약과 동일) — the composer is gated exactly like kill/relay. + const html = renderToStaticMarkup( + createElement(SidebarLiveSection, { + projects: makeProjects(), + liveSessionIds: new Set(['zz-unmatched-id']), + liveSessionNames: new Map([['zz-unmatched-id', 'somewhere']]), + liveSessionLineage: new Set(), + liveSessionTmuxIds: new Map(), + selectedSession: null, + onSessionSelect, + }), + ); + assert.ok(html.includes('somewhere'), 'row is still visible'); + assert.ok(!html.includes('첫 메시지 보내기'), 'no composer without a lineage claim'); }); diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx index 0e935d5b06..96b5679908 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx @@ -30,6 +30,13 @@ type KillStatus = | { kind: 'killing' } | { kind: 'error'; text: string }; +/** Per-idle-row first-message flow state (web-only usage must not require tmux). */ +type IdleSendStatus = + | { kind: 'idle' } + | { kind: 'sending' } + | { kind: 'sent'; text: string } + | { kind: 'error'; text: string }; + /** Compact relative age for a session's last activity: <1m, Xm, Xhr, Xd, or ''. */ function formatAge(iso: string): string { const time = new Date(iso).getTime(); @@ -68,6 +75,8 @@ export default function SidebarLiveSection({ // live poll is the source of truth and will drop them for real. const [killedIds, setKilledIds] = useState>(new Set()); const [killStatus, setKillStatus] = useState>(new Map()); + const [idleDrafts, setIdleDrafts] = useState>(new Map()); + const [idleSendStatus, setIdleSendStatus] = useState>(new Map()); // Reconcile row-local state with each authoritative snapshot (리뷰 반영): // ids the poll no longer reports drop their killed/confirm/error state, so a @@ -82,6 +91,14 @@ export default function SidebarLiveSection({ const next = new Map([...prev].filter(([id]) => liveSessionIds.has(id))); return next.size === prev.size ? prev : next; }); + setIdleDrafts((prev) => { + const next = new Map([...prev].filter(([id]) => liveSessionIds.has(id))); + return next.size === prev.size ? prev : next; + }); + setIdleSendStatus((prev) => { + const next = new Map([...prev].filter(([id]) => liveSessionIds.has(id))); + return next.size === prev.size ? prev : next; + }); }, [liveSessionIds]); if (liveSessionIds.size === 0) { @@ -151,6 +168,95 @@ export default function SidebarLiveSection({ } }; + const idleStatusOf = (id: string): IdleSendStatus => idleSendStatus.get(id) ?? { kind: 'idle' }; + const setIdleStatusOf = (id: string, status: IdleSendStatus) => { + setIdleSendStatus((prev) => { + const next = new Map(prev); + if (status.kind === 'idle') { + next.delete(id); + } else { + next.set(id, status); + } + return next; + }); + }; + + // First message into an idle gjc pane, straight from the web (mobile/web-only + // usage must not require a tmux hop). Same relay path as LiveRelayComposer: + // the tower types into the pane; gjc then opens its transcript and the 5s + // poll transitions this row into a real, openable session. + const sendFirstMessage = async (id: string, tmuxName: string) => { + const message = (idleDrafts.get(id) ?? '').trim(); + if (!message || idleStatusOf(id).kind === 'sending') { + return; + } + setIdleStatusOf(id, { kind: 'sending' }); + try { + const response = await api.liveSessionSend(tmuxName, message); + const body = await response.json().catch(() => null); + const data = (body?.data ?? body ?? {}) as { ok?: boolean; reachable?: boolean; queued?: boolean; detail?: string }; + if (data.reachable === false) { + setIdleStatusOf(id, { kind: 'error', text: '관제탑 미가동 — 전송 불가' }); + return; + } + if (!response.ok || data.ok === false) { + setIdleStatusOf(id, { kind: 'error', text: (typeof body?.error === 'string' && body.error) || data.detail || '전송 실패' }); + return; + } + setIdleDrafts((prev) => { + const next = new Map(prev); + next.delete(id); + return next; + }); + setIdleStatusOf(id, { + kind: 'sent', + text: data.queued ? '대기열 등록됨 — 준비되면 전달됩니다' : '전달됨 — 곧 대화로 나타납니다', + }); + } catch { + setIdleStatusOf(id, { kind: 'error', text: '전송 실패' }); + } + }; + + /** Inline first-message composer for lineage-grade idle rows. */ + const idleComposer = (id: string, tmuxName: string) => { + const status = idleStatusOf(id); + return ( +
+
+ { + const value = event.target.value; + setIdleDrafts((prev) => new Map(prev).set(id, value)); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault(); + void sendFirstMessage(id, tmuxName); + } + }} + placeholder="첫 메시지 보내기… (Enter 전송)" + className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/60" + /> + +
+ {(status.kind === 'sent' || status.kind === 'error') && ( +

+ {status.text} +

+ )} +
+ ); + }; + // Shared kill affordances (matched rows + orphan rows use the same flow). const killButton = (id: string, tmuxName: string) => statusOf(id).kind === 'idle' ? ( @@ -277,12 +383,13 @@ export default function SidebarLiveSection({ {isIdle - ? '아직 대화가 없습니다 — 첫 메시지 후 열람할 수 있습니다' + ? '아직 대화가 없습니다 — 웹에서 바로 첫 메시지를 보낼 수 있습니다' : '대화 미로딩 — 해당 프로젝트를 열면 제목이 표시됩니다'} {tmuxName && liveSessionLineage.has(id) && killButton(id, tmuxName)} + {isIdle && tmuxName && liveSessionLineage.has(id) && idleComposer(id, tmuxName)} {tmuxName && liveSessionLineage.has(id) && killStrip(id, tmuxName)} ); From 7fbf6f1bac5542e139c3e497ed087920fdd6f7bf Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:39:48 +0900 Subject: [PATCH 06/26] =?UTF-8?q?fix(sidebar):=20idle=20=ED=96=89=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EC=A0=95=20=E2=80=94=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=EA=B0=80=20=EC=9D=B4=EB=AF=B8=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=20=EC=84=B8=EC=85=98=EB=8F=84=20idle?= =?UTF-8?q?=EB=A1=9C=20=EB=9C=AC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit idle 행의 기준은 '대화 없음'이 아니라 'transcript fd 미보유'다: 턴이 끝나 프롬프트에서 쉬는 세션은 대화가 있어도 idle 행으로 돌아온다(실측: 첫 메시지 응답 후에도 '아직 대화가 없습니다' 표시). 두 상태 모두에 정직한 문구로 교체. --- .../sidebar/view/subcomponents/SidebarLiveSection.test.tsx | 2 +- .../sidebar/view/subcomponents/SidebarLiveSection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx index a82312e66e..b4103953d4 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx @@ -87,7 +87,7 @@ test('SidebarLiveSection renders idle-gjc rows as 대기 (첫 대화 전 gjc pan assert.ok(html.includes('>flask<'), 'labels the row by tmux session name'); assert.ok(html.includes('대기'), 'idle rows carry the 대기 badge, not LIVE'); assert.ok(!html.includes('LIVE'), 'no LIVE badge for a session with no transcript'); - assert.ok(html.includes('아직 대화가 없습니다'), 'explains why it is not openable yet'); + assert.ok(html.includes('프롬프트 대기 중'), 'explains the row is awaiting input, without claiming no conversation exists'); assert.ok(html.includes('tmux 세션 flask 닫기'), 'lineage-grade idle rows keep the kill control'); assert.ok(html.includes('첫 메시지 보내기'), 'lineage-grade idle rows offer the inline first-message composer'); }); diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx index 96b5679908..a9343c881f 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx @@ -383,7 +383,7 @@ export default function SidebarLiveSection({ {isIdle - ? '아직 대화가 없습니다 — 웹에서 바로 첫 메시지를 보낼 수 있습니다' + ? '프롬프트 대기 중 — 메시지를 보내면 tmux 세션에 바로 전달됩니다' : '대화 미로딩 — 해당 프로젝트를 열면 제목이 표시됩니다'} From 4125e512e5f3f35bb8c5a73dcc112ea8bdafed5f Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:44:29 +0900 Subject: [PATCH 07/26] feat(types): IdleGjcTarget(+excludedSessionIds) + MainTakeover union --- src/types/app.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/types/app.ts b/src/types/app.ts index d2e4777324..ab48133d90 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -35,6 +35,16 @@ export type ExternalTerminalTarget = { kind: string; project: Project; }; +/** Idle gjc pane — full main-area waiting view. tmuxId non-null[P1-1]. + * excludedSessionIds: 뷰 오픈 시점에 이미 적격이던 세션 id — 전환은 이후 신규 관측 후보만 대상[P1-C]. */ +export type IdleGjcTarget = { + kind: 'idle-gjc'; tmuxName: string; tmuxId: string; excludedSessionIds: readonly string[]; +}; +/** 메인 영역 takeover — external·idle 상호배타 타입 강제. [P2-4] */ +export type MainTakeover = + | { kind: 'external'; target: ExternalTerminalTarget } + | { kind: 'idle-gjc'; target: IdleGjcTarget } + | null; export interface ProjectSession { id: string; From 110d4f6321387196438fd1e27eb8174fb7dac14b Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:44:29 +0900 Subject: [PATCH 08/26] feat(app): pure idle-transition helpers + tests --- src/components/app/idleTransition.test.ts | 219 ++++++++++++++++++++++ src/components/app/idleTransition.ts | 84 +++++++++ 2 files changed, 303 insertions(+) create mode 100644 src/components/app/idleTransition.test.ts create mode 100644 src/components/app/idleTransition.ts diff --git a/src/components/app/idleTransition.test.ts b/src/components/app/idleTransition.test.ts new file mode 100644 index 0000000000..b58b0e0951 --- /dev/null +++ b/src/components/app/idleTransition.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { IdleGjcTarget } from '../../types/app'; +import { + buildIdleTarget, + composerKey, + computeIdleStep, + isGenerationReplaced, + newEligibleSessionIds, + nextResolvingOnStep, +} from './idleTransition'; + +const idleId = 'idle-gjc:flask'; +const tmuxName = 'flask'; +const tmuxId = '$1'; + +function target(excludedSessionIds: readonly string[] = []): IdleGjcTarget { + return { kind: 'idle-gjc', tmuxName, tmuxId, excludedSessionIds }; +} + +test('buildIdleTarget rejects non-idle ids', () => { + const names = new Map([['session-1', tmuxName]]); + const lineage = new Set(['session-1']); + const tmuxIds = new Map([['session-1', tmuxId]]); + + assert.equal(buildIdleTarget('session-1', names, lineage, tmuxIds), null); +}); + +test('buildIdleTarget rejects an idle id without a tmux name', () => { + const names = new Map(); + const lineage = new Set([idleId]); + const tmuxIds = new Map([[idleId, tmuxId]]); + + assert.equal(buildIdleTarget(idleId, names, lineage, tmuxIds), null); +}); + +test('buildIdleTarget rejects an idle id without a tmux generation', () => { + const names = new Map([[idleId, tmuxName]]); + const lineage = new Set([idleId]); + const tmuxIds = new Map(); + + assert.equal(buildIdleTarget(idleId, names, lineage, tmuxIds), null); +}); + +test('buildIdleTarget rejects an idle id outside the live-session lineage', () => { + const names = new Map([[idleId, tmuxName]]); + const lineage = new Set(); + const tmuxIds = new Map([[idleId, tmuxId]]); + + assert.equal(buildIdleTarget(idleId, names, lineage, tmuxIds), null); +}); + +test('buildIdleTarget captures eligible sessions when the waiting view opens', () => { + const names = new Map([ + [idleId, tmuxName], + ['existing', tmuxName], + ['wrong-generation', tmuxName], + ['outside-lineage', tmuxName], + ]); + const lineage = new Set([idleId, 'existing', 'wrong-generation']); + const tmuxIds = new Map([ + [idleId, tmuxId], + ['existing', tmuxId], + ['wrong-generation', '$2'], + ['outside-lineage', tmuxId], + ]); + + assert.deepEqual(buildIdleTarget(idleId, names, lineage, tmuxIds), { + kind: 'idle-gjc', + tmuxName, + tmuxId, + excludedSessionIds: ['existing'], + }); +}); + +test('newEligibleSessionIds excludes candidates present when the waiting view opened', () => { + const names = new Map([ + ['existing', tmuxName], + ['new-session', tmuxName], + ['wrong-generation', tmuxName], + ]); + const lineage = new Set(['existing', 'new-session', 'wrong-generation']); + const tmuxIds = new Map([ + ['existing', tmuxId], + ['new-session', tmuxId], + ['wrong-generation', '$2'], + ]); + + assert.deepEqual(newEligibleSessionIds(target(['existing']), names, lineage, tmuxIds), ['new-session']); +}); + +test('isGenerationReplaced treats source removal as a normal transition', () => { + assert.equal(isGenerationReplaced(target(), new Map()), false); +}); + +test('isGenerationReplaced accepts the current source generation', () => { + assert.equal(isGenerationReplaced(target(), new Map([[idleId, tmuxId]])), false); +}); + +test('isGenerationReplaced detects an observed source generation replacement', () => { + assert.equal(isGenerationReplaced(target(), new Map([[idleId, '$2']])), true); +}); + +test('computeIdleStep stays idle when stale debounce candidates were captured at open time', () => { + const names = new Map([[idleId, tmuxName], ['stale-session', tmuxName]]); + const lineage = new Set([idleId, 'stale-session']); + const tmuxIds = new Map([[idleId, tmuxId], ['stale-session', tmuxId]]); + const opened = buildIdleTarget(idleId, names, lineage, tmuxIds); + + assert.ok(opened); + assert.deepEqual(computeIdleStep(opened, names, lineage, tmuxIds, () => true), { type: 'idle' }); +}); + +test('computeIdleStep reports ambiguous for two or more new eligible candidates', () => { + const names = new Map([['first', tmuxName], ['second', tmuxName]]); + const lineage = new Set(['first', 'second']); + const tmuxIds = new Map([['first', tmuxId], ['second', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { type: 'ambiguous' }); +}); + +test('computeIdleStep resolves a new candidate after the idle source disappears', () => { + const names = new Map([['new-session', tmuxName]]); + const lineage = new Set(['new-session']); + const tmuxIds = new Map([['new-session', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { + type: 'resolving', + targetId: 'new-session', + }); +}); + +test('computeIdleStep navigates once the new candidate owner is loaded', () => { + const names = new Map([['new-session', tmuxName]]); + const lineage = new Set(['new-session']); + const tmuxIds = new Map([['new-session', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, (id) => id === 'new-session'), { + type: 'navigate', + targetId: 'new-session', + }); +}); + +test('computeIdleStep remains idle when no candidate exists after the source disappears', () => { + assert.deepEqual( + computeIdleStep(target(), new Map(), new Set(), new Map(), () => false), + { type: 'idle' }, + ); +}); + +test('computeIdleStep invalidates before evaluating candidates after a generation replacement', () => { + const names = new Map([[idleId, tmuxName], ['new-session', tmuxName]]); + const lineage = new Set([idleId, 'new-session']); + const tmuxIds = new Map([[idleId, '$2'], ['new-session', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => true), { type: 'invalidate' }); +}); + +test('computeIdleStep supports poll-first candidate then owner loading', () => { + const names = new Map([['new-session', tmuxName]]); + const lineage = new Set(['new-session']); + const tmuxIds = new Map([['new-session', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { + type: 'resolving', + targetId: 'new-session', + }); + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => true), { + type: 'navigate', + targetId: 'new-session', + }); +}); + +test('computeIdleStep supports upsert-first owner then candidate arrival', () => { + const noCandidates = new Map(); + const candidateNames = new Map([['new-session', tmuxName]]); + const lineage = new Set(['new-session']); + const tmuxIds = new Map([['new-session', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), noCandidates, lineage, tmuxIds, () => true), { type: 'idle' }); + assert.deepEqual(computeIdleStep(target(), candidateNames, lineage, tmuxIds, () => true), { + type: 'navigate', + targetId: 'new-session', + }); +}); + +test('composerKey changes when either pane identity component changes', () => { + assert.notEqual(composerKey('flask', '$1'), composerKey('flask', '$2')); + assert.notEqual(composerKey('flask', '$1'), composerKey('beaker', '$1')); +}); + +test('nextResolvingOnStep replaces a changed target and preserves an unchanged timed-out target', () => { + const timedOut = { targetId: 'first', startedAt: 10, timedOut: true }; + + assert.deepEqual(nextResolvingOnStep(timedOut, { type: 'resolving', targetId: 'second' }, 20), { + targetId: 'second', + startedAt: 20, + timedOut: false, + }); + assert.strictEqual( + nextResolvingOnStep(timedOut, { type: 'resolving', targetId: 'first' }, 20), + timedOut, + ); +}); + +test('nextResolvingOnStep clears for idle, ambiguous, and invalidated states', () => { + const resolving = { targetId: 'session', startedAt: 10, timedOut: false }; + + assert.equal(nextResolvingOnStep(resolving, { type: 'idle' }, 20), null); + assert.equal(nextResolvingOnStep(resolving, { type: 'ambiguous' }, 20), null); + assert.equal(nextResolvingOnStep(resolving, { type: 'invalidate' }, 20), null); +}); + +test('nextResolvingOnStep clears before navigation', () => { + const resolving = { targetId: 'session', startedAt: 10, timedOut: false }; + + assert.equal(nextResolvingOnStep(resolving, { type: 'navigate', targetId: 'session' }, 20), null); +}); diff --git a/src/components/app/idleTransition.ts b/src/components/app/idleTransition.ts new file mode 100644 index 0000000000..9406b9bfc7 --- /dev/null +++ b/src/components/app/idleTransition.ts @@ -0,0 +1,84 @@ +import type { IdleGjcTarget } from '../../types/app'; + +/** 현재 맵에서 (동명+lineage+동일 tmuxId) 적격 실 세션 id 전체. synthetic 제외. */ +function eligibleSessionIds(tmuxName: string, tmuxId: string, + names: ReadonlyMap, lineage: ReadonlySet, + tmuxIds: ReadonlyMap): string[] { + const out: string[] = []; + for (const [id, name] of names) { + if (id.startsWith('idle-gjc:')) continue; + if (name !== tmuxName) continue; + if (!lineage.has(id)) continue; + if (tmuxIds.get(id) !== tmuxId) continue; // missing/mismatch=제외(fail-closed)[P1-1] + out.push(id); + } + return out; +} + +/** 진입점에서 호출 — 현재 맵으로 tmuxName/tmuxId 도출 + 오픈 시점 적격자를 excluded로 캡처. [P1-C] */ +export function buildIdleTarget(id: string, + names: ReadonlyMap, lineage: ReadonlySet, + tmuxIds: ReadonlyMap): IdleGjcTarget | null { + const tmuxName = names.get(id); const tmuxId = tmuxIds.get(id); + if (!id.startsWith('idle-gjc:') || !tmuxName || !tmuxId || !lineage.has(id)) return null; + return { kind: 'idle-gjc', tmuxName, tmuxId, + excludedSessionIds: eligibleSessionIds(tmuxName, tmuxId, names, lineage, tmuxIds) }; +} + +/** excluded 제외한 **신규 관측** 적격 후보. [P1-C] */ +export function newEligibleSessionIds(target: IdleGjcTarget, + names: ReadonlyMap, lineage: ReadonlySet, + tmuxIds: ReadonlyMap): string[] { + const excluded = new Set(target.excludedSessionIds); + return eligibleSessionIds(target.tmuxName, target.tmuxId, names, lineage, tmuxIds) + .filter((id) => !excluded.has(id)); +} + +/** 무효화는 **관측된 세대 교체**에만. 단순 소멸(undefined)은 정상 전환 수반이므로 false. [P1-A] */ +export function isGenerationReplaced(target: IdleGjcTarget, + tmuxIds: ReadonlyMap): boolean { + const src = tmuxIds.get(`idle-gjc:${target.tmuxName}`); + return src !== undefined && src !== target.tmuxId; +} + +export type IdleStep = + | { type: 'invalidate' } // 세대 교체 관측 → takeover 해제 + | { type: 'idle' } // 신규 후보 0 → idle 무기한 유지 + | { type: 'ambiguous' } // 신규 후보 ≥2 → 배너, 자동전환 안 함 [P1-C] + | { type: 'resolving'; targetId: string } // 신규 후보 1, owner 미로드 + | { type: 'navigate'; targetId: string }; // 신규 후보 1 + owner 로드 + +export function computeIdleStep(target: IdleGjcTarget, + names: ReadonlyMap, lineage: ReadonlySet, + tmuxIds: ReadonlyMap, ownerLoaded: (id: string) => boolean): IdleStep { + if (isGenerationReplaced(target, tmuxIds)) return { type: 'invalidate' }; + const cands = newEligibleSessionIds(target, names, lineage, tmuxIds); + if (cands.length === 0) return { type: 'idle' }; + if (cands.length > 1) return { type: 'ambiguous' }; // 다중 transcript 정상 구성 → 자동전환 거부[P1-C] + const targetId = cands[0]; + return ownerLoaded(targetId) ? { type: 'navigate', targetId } : { type: 'resolving', targetId }; +} + +export function composerKey(name: string, tmuxId: string): string { + return `${name}:${tmuxId}`; +} + +export type ResolvingState = { + targetId: string; + startedAt: number; + timedOut: boolean; +}; + +/** + * Keeps a resolving timer only while it tracks the same candidate. Supplying + * startedAt makes target replacement deterministic for tests. + */ +export function nextResolvingOnStep( + prev: ResolvingState | null, + step: IdleStep, + startedAt = Date.now(), +): ResolvingState | null { + if (step.type !== 'resolving') return null; + if (prev?.targetId === step.targetId) return prev; + return { targetId: step.targetId, startedAt, timedOut: false }; +} From e93a06be147c53e8f639f1c1afb537804d901ec3 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:51:37 +0900 Subject: [PATCH 09/26] feat(main+app): idle waiting view, takeover union, resolve-then-navigate, resolving+ambiguous banners --- src/components/app/AppContent.tsx | 133 +++++++++++++++--- src/components/app/idleTransition.test.ts | 1 + src/components/main-content/types/types.ts | 6 +- .../main-content/view/MainContent.tsx | 64 +++++++++ 4 files changed, 183 insertions(+), 21 deletions(-) diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 678e3f8eba..7756481b8e 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -12,7 +12,9 @@ import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; import { useQueuedMessageAutoSend } from '../../hooks/useQueuedMessageAutoSend'; import { api } from '../../utils/api'; -import type { ExternalTerminalTarget } from '../../types/app'; +import type { ExternalTerminalTarget, IdleGjcTarget, MainTakeover } from '../../types/app'; + +import { computeIdleStep, nextResolvingOnStep, type ResolvingState } from './idleTransition'; type RunningSessionApiItem = { sessionId?: unknown; @@ -63,6 +65,7 @@ function AppContentInner() { } = useSessionProtection(); const { + projects, selectedProject, selectedSession, liveSessionModels, @@ -87,39 +90,56 @@ function AppContentInner() { activeSessions: processingSessions, }); - // External CLI (claude/codex) tmux terminal shown in the main area. Lives - // here (not in useProjectsState) so the gjc session flow stays untouched; - // selecting any project/session or starting a new chat clears it via the - // wrapped sidebar handlers below. - const [externalTerminal, setExternalTerminal] = useState(null); + // Main-area takeovers are mutually exclusive. This stays outside + // useProjectsState so normal gjc route selection remains unchanged. + const [takeover, setTakeover] = useState(null); + const [resolving, setResolving] = useState(null); + const [idleAmbiguous, setIdleAmbiguous] = useState(false); + const externalTerminal = takeover?.kind === 'external' ? takeover.target : null; + const idleTarget = takeover?.kind === 'idle-gjc' ? takeover.target : null; + const resolvingTargetId = resolving?.targetId; + const resolvingStartedAt = resolving?.startedAt; + const resolvingTimedOut = resolving?.timedOut ?? false; + + const reset = useCallback(() => { + setTakeover(null); + setResolving(null); + setIdleAmbiguous(false); + }, []); const openExternalTerminal = useCallback((target: ExternalTerminalTarget) => { - setExternalTerminal(target); + setTakeover({ kind: 'external', target }); + setResolving(null); + setIdleAmbiguous(false); setSidebarOpen(false); }, [setSidebarOpen]); - const closeExternalTerminal = useCallback(() => { - setExternalTerminal(null); - }, []); + const openIdleTarget = useCallback((target: IdleGjcTarget) => { + setTakeover({ kind: 'idle-gjc', target }); + setResolving(null); + setIdleAmbiguous(false); + setSidebarOpen(false); + }, [setSidebarOpen]); // Wrap navigation-ish sidebar handlers so leaving for a session/project/new - // chat drops the terminal takeover — without modifying the originals. + // chat drops any main-area takeover — without modifying the originals. const sidebarProps = useMemo(() => ({ ...sidebarSharedProps, onProjectSelect: (...args: Parameters) => { - setExternalTerminal(null); + reset(); return sidebarSharedProps.onProjectSelect(...args); }, onSessionSelect: (...args: Parameters) => { - setExternalTerminal(null); + reset(); return sidebarSharedProps.onSessionSelect(...args); }, onNewSession: (...args: Parameters) => { - setExternalTerminal(null); + reset(); return sidebarSharedProps.onNewSession(...args); }, onExternalTerminalOpen: openExternalTerminal, - }), [sidebarSharedProps, openExternalTerminal]); + onIdleSessionOpen: openIdleTarget, + }), [sidebarSharedProps, reset, openExternalTerminal, openIdleTarget]); // Queued messages for sessions that finish while another session (or none) // is being viewed are sent from here; the viewed session's composer handles @@ -181,6 +201,75 @@ function AppContentInner() { openSettings, refreshProjects: refreshProjectsSilently, }); + useEffect(() => { + reset(); + }, [reset, sessionId]); + + useEffect(() => { + if (!idleTarget) { + return; + } + + const ownerLoaded = (targetId: string) => projects.some( + (project) => project.sessions?.some((session) => session.id === targetId), + ); + const step = computeIdleStep( + idleTarget, + sidebarSharedProps.liveSessionNames, + sidebarSharedProps.liveSessionLineage, + sidebarSharedProps.liveSessionTmuxIds, + ownerLoaded, + ); + + switch (step.type) { + case 'invalidate': + reset(); + return; + case 'idle': + setResolving(null); + setIdleAmbiguous(false); + return; + case 'ambiguous': + setResolving(null); + setIdleAmbiguous(true); + return; + case 'resolving': + setIdleAmbiguous(false); + setResolving((current) => nextResolvingOnStep(current, step)); + void refreshProjectsSilently(); + return; + case 'navigate': + reset(); + navigate(`/session/${step.targetId}`); + return; + } + }, [ + idleTarget, + navigate, + projects, + refreshProjectsSilently, + reset, + sidebarSharedProps.liveSessionLineage, + sidebarSharedProps.liveSessionNames, + sidebarSharedProps.liveSessionTmuxIds, + ]); + + useEffect(() => { + if (!resolvingTargetId || resolvingTimedOut) { + return undefined; + } + + const timeout = window.setTimeout(() => { + setResolving((current) => ( + current && current.targetId === resolvingTargetId + ? { ...current, timedOut: true } + : current + )); + }, 15_000); + + return () => window.clearTimeout(timeout); + }, [resolvingStartedAt, resolvingTargetId, resolvingTimedOut]); + useEffect(() => { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { @@ -197,7 +286,7 @@ function AppContentInner() { localStorage.setItem('selected-provider', message.provider); } - setExternalTerminal(null); + reset(); setActiveTab('chat'); setSidebarOpen(false); void refreshProjectsSilently(); @@ -215,7 +304,7 @@ function AppContentInner() { return () => { navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); }; - }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); + }, [navigate, refreshProjectsSilently, reset, setActiveTab, setSidebarOpen]); // Pending tool permissions are recovered through the `chat.subscribe` flow: // the `chat_subscribed` ack carries them on session open and on reconnect, @@ -315,19 +404,23 @@ function AppContentInner() { externalMessageUpdate={externalMessageUpdate} newSessionTrigger={newSessionTrigger} externalTerminal={externalTerminal} - onExternalTerminalClose={closeExternalTerminal} + onExternalTerminalClose={reset} + idleTarget={idleTarget} + onIdleClose={reset} + resolvingTimedOut={resolvingTimedOut} + idleAmbiguous={idleAmbiguous} /> ) => { - setExternalTerminal(null); + reset(); return handleNewSession(...args); }} onOpenSettings={() => openSettings()} onShowTab={(tab: Parameters[0]) => { - setExternalTerminal(null); + reset(); setActiveTab(tab); }} /> diff --git a/src/components/app/idleTransition.test.ts b/src/components/app/idleTransition.test.ts index b58b0e0951..ebd6ac6a62 100644 --- a/src/components/app/idleTransition.test.ts +++ b/src/components/app/idleTransition.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { IdleGjcTarget } from '../../types/app'; + import { buildIdleTarget, composerKey, diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index df086c3592..ff3bd37318 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; -import type { AppTab, ExternalTerminalTarget, Project, ProjectSession } from '../../../types/app'; +import type { AppTab, ExternalTerminalTarget, IdleGjcTarget, Project, ProjectSession } from '../../../types/app'; import type { MarkSessionIdle, MarkSessionProcessing, @@ -64,6 +64,10 @@ export type MainContentProps = { // External CLI (claude/codex) tmux terminal shown as the full main area. externalTerminal: ExternalTerminalTarget | null; onExternalTerminalClose: () => void; + idleTarget: IdleGjcTarget | null; + onIdleClose: () => void; + resolvingTimedOut: boolean; + idleAmbiguous: boolean; }; export type MainContentHeaderProps = { diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index d14c542c30..cd23ea771f 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Menu, SquareTerminal, X } from 'lucide-react'; import ChatInterface from '../../chat/view/ChatInterface'; +import LiveRelayComposer from '../../chat/view/subcomponents/LiveRelayComposer'; +import { composerKey } from '../../app/idleTransition'; import PluginTabContent from '../../plugins/view/PluginTabContent'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import { BrowserUsePanel } from '../../browser-use'; @@ -58,6 +60,10 @@ function MainContent({ newSessionTrigger, externalTerminal, onExternalTerminalClose, + idleTarget, + onIdleClose, + resolvingTimedOut, + idleAmbiguous, }: MainContentProps) { const { preferences } = useUiPreferences(); const { showRawParameters, showThinking, sendByCtrlEnter } = preferences; @@ -220,6 +226,64 @@ function MainContent({ ); } + if (idleTarget) { + const safeName = /^[A-Za-z0-9._-]{1,64}$/.test(idleTarget.tmuxName) ? idleTarget.tmuxName : null; + return ( +
+
+
+ {isMobile && ( + + )} + + 대기 + tmux:{idleTarget.tmuxName} +
+ +
+
+
+ +

아직 대화가 없습니다

+

아래에서 첫 메시지를 보내면 이 tmux 세션의 대화가 시작됩니다.

+ {resolvingTimedOut && ( +

+ 대화를 불러오는 중… 오래 걸리면 사이드바에서 직접 열 수 있습니다 +

+ )} + {idleAmbiguous && ( +

+ 이 tmux 세션에 대화 후보가 여럿 감지됨 — 사이드바 목록에서 직접 선택하세요 +

+ )} +
+
+ {safeName && ( + + )} +
+ ); + } if (!selectedProject) { return ; From 90157a9b15d71c82abe7071cff796d4d3bb00bf4 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:51:37 +0900 Subject: [PATCH 10/26] feat(sidebar): idle rows open the main-area waiting view; remove inline first-message composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계획상 커밋 4(스레딩+진입점+테스트 noop)와 5(컴포저 제거+테스트 갱신)를 병합 — executor 슬라이스가 최종 상태 트리를 산출해 중간 상태 재구성은 리스크만 추가. 내용은 계획 §2 Step 6·7, §3 그대로. 병합 근거는 ultragoal ledger에 기록. --- src/components/sidebar/types/types.ts | 3 +- src/components/sidebar/view/Sidebar.tsx | 2 + .../view/subcomponents/SidebarContent.tsx | 5 +- .../subcomponents/SidebarLiveSection.test.tsx | 36 +++- .../view/subcomponents/SidebarLiveSection.tsx | 173 +++++------------- 5 files changed, 80 insertions(+), 139 deletions(-) diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 27a518476c..d1c4c0d969 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -1,4 +1,4 @@ -import type { ExternalTerminalTarget, LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { ExternalTerminalTarget, IdleGjcTarget, LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; export type ProjectSortOrder = 'name' | 'date'; @@ -68,6 +68,7 @@ export type SidebarProps = { isMobile: boolean; // Opens an external CLI (claude/codex) tmux session as a full main-area terminal. onExternalTerminalOpen: (target: ExternalTerminalTarget) => void; + onIdleSessionOpen: (target: IdleGjcTarget) => void; }; export type SessionViewModel = { diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 1af3ac200e..5cbdfdaab8 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -46,6 +46,7 @@ function Sidebar({ onCloseSettings, isMobile, onExternalTerminalOpen, + onIdleSessionOpen, }: SidebarProps) { const { t } = useTranslation(['sidebar', 'common']); const { isPWA } = useDeviceSettings({ trackMobile: false }); @@ -316,6 +317,7 @@ function Sidebar({ liveSessionLineage={liveSessionLineage} liveSessionTmuxIds={liveSessionTmuxIds} onExternalTerminalOpen={onExternalTerminalOpen} + onIdleSessionOpen={onIdleSessionOpen} t={t} /> diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 734c826ea5..e18d6fcc63 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -3,7 +3,7 @@ import { Activity, Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } f import type { TFunction } from 'i18next'; import { ScrollArea } from '../../../../shared/view/ui'; -import type { ExternalTerminalTarget, Project } from '../../../../types/app'; +import type { ExternalTerminalTarget, IdleGjcTarget, Project } from '../../../../types/app'; import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types'; @@ -156,6 +156,7 @@ type SidebarContentProps = { liveSessionLineage: ReadonlySet; liveSessionTmuxIds: ReadonlyMap; onExternalTerminalOpen: (target: ExternalTerminalTarget) => void; + onIdleSessionOpen: (target: IdleGjcTarget) => void; t: TFunction; }; @@ -198,6 +199,7 @@ export default function SidebarContent({ liveSessionLineage, liveSessionTmuxIds, onExternalTerminalOpen, + onIdleSessionOpen, t, }: SidebarContentProps) { const [topTab, setTopTab] = useState<'live' | 'external' | 'archive'>('live'); @@ -291,6 +293,7 @@ export default function SidebarContent({ liveSessionNames={liveSessionNames} liveSessionLineage={liveSessionLineage} liveSessionTmuxIds={liveSessionTmuxIds} + onIdleSessionOpen={onIdleSessionOpen} /> )} diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx index b4103953d4..84749e015e 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx @@ -34,6 +34,7 @@ test('SidebarLiveSection labels rows by tmux session name, title in tooltip', () liveSessionTmuxIds: new Map([['s-live', '$1']]), selectedSession: null, onSessionSelect, + onIdleSessionOpen: noop, }), ); assert.ok(html.includes('>omg<'), 'primary label is the tmux session name'); @@ -52,6 +53,7 @@ test('SidebarLiveSection falls back to the conversation title when tmux name is liveSessionTmuxIds: new Map(), selectedSession: null, onSessionSelect, + onIdleSessionOpen: noop, }), ); assert.ok(html.includes('Live conversation title'), 'primary label falls back to the title'); @@ -67,6 +69,7 @@ test('SidebarLiveSection renders nothing when no session is live', () => { liveSessionTmuxIds: new Map(), selectedSession: null, onSessionSelect, + onIdleSessionOpen: noop, }), ); assert.equal(html, ''); @@ -82,30 +85,47 @@ test('SidebarLiveSection renders idle-gjc rows as 대기 (첫 대화 전 gjc pan liveSessionTmuxIds: new Map([['idle-gjc:flask', '$9']]), selectedSession: null, onSessionSelect, + onIdleSessionOpen: noop, }), ); assert.ok(html.includes('>flask<'), 'labels the row by tmux session name'); assert.ok(html.includes('대기'), 'idle rows carry the 대기 badge, not LIVE'); assert.ok(!html.includes('LIVE'), 'no LIVE badge for a session with no transcript'); - assert.ok(html.includes('프롬프트 대기 중'), 'explains the row is awaiting input, without claiming no conversation exists'); + assert.ok(html.includes('클릭하면 메인 영역에서 첫 메시지를 보냅니다'), 'explains that input now happens in the main area'); assert.ok(html.includes('tmux 세션 flask 닫기'), 'lineage-grade idle rows keep the kill control'); - assert.ok(html.includes('첫 메시지 보내기'), 'lineage-grade idle rows offer the inline first-message composer'); + assert.ok(html.includes('aria-label="flask 대기 세션 열기"'), 'lineage-grade idle rows open the main waiting view'); }); -test('SidebarLiveSection: non-lineage rows never get the first-message composer', () => { - // A tmuxName without lineage proof must not receive keystrokes (patina 실사고 - // 계약과 동일) — the composer is gated exactly like kill/relay. +test('SidebarLiveSection: non-lineage idle rows cannot open the waiting view', () => { const html = renderToStaticMarkup( createElement(SidebarLiveSection, { projects: makeProjects(), - liveSessionIds: new Set(['zz-unmatched-id']), - liveSessionNames: new Map([['zz-unmatched-id', 'somewhere']]), + liveSessionIds: new Set(['idle-gjc:somewhere']), + liveSessionNames: new Map([['idle-gjc:somewhere', 'somewhere']]), liveSessionLineage: new Set(), + liveSessionTmuxIds: new Map([['idle-gjc:somewhere', '$10']]), + selectedSession: null, + onSessionSelect, + onIdleSessionOpen: noop, + }), + ); + assert.ok(html.includes('somewhere'), 'row is still visible'); + assert.ok(!html.includes('대기 세션 열기'), 'no waiting-view button without a lineage claim'); +}); + +test('SidebarLiveSection: idle rows without a tmux generation cannot open the waiting view', () => { + const html = renderToStaticMarkup( + createElement(SidebarLiveSection, { + projects: makeProjects(), + liveSessionIds: new Set(['idle-gjc:somewhere']), + liveSessionNames: new Map([['idle-gjc:somewhere', 'somewhere']]), + liveSessionLineage: new Set(['idle-gjc:somewhere']), liveSessionTmuxIds: new Map(), selectedSession: null, onSessionSelect, + onIdleSessionOpen: noop, }), ); assert.ok(html.includes('somewhere'), 'row is still visible'); - assert.ok(!html.includes('첫 메시지 보내기'), 'no composer without a lineage claim'); + assert.ok(!html.includes('대기 세션 열기'), 'no waiting-view button without a tmux generation'); }); diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx index a9343c881f..6833cf9350 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import { X } from 'lucide-react'; -import type { Project, ProjectSession } from '../../../../types/app'; +import type { IdleGjcTarget, Project, ProjectSession } from '../../../../types/app'; import { cn } from '../../../../lib/utils'; import { api } from '../../../../utils/api'; import { getAllSessions, getSessionTime } from '../../utils/utils'; +import { buildIdleTarget } from '../../../app/idleTransition'; import type { SidebarProjectListProps } from './SidebarProjectList'; @@ -21,6 +22,7 @@ type SidebarLiveSectionProps = { liveSessionTmuxIds: ReadonlyMap; selectedSession: ProjectSession | null; onSessionSelect: SidebarProjectListProps['onSessionSelect']; + onIdleSessionOpen: (target: IdleGjcTarget) => void; }; /** Per-row kill flow state (2-step confirm before the tower is asked to kill). */ @@ -30,13 +32,6 @@ type KillStatus = | { kind: 'killing' } | { kind: 'error'; text: string }; -/** Per-idle-row first-message flow state (web-only usage must not require tmux). */ -type IdleSendStatus = - | { kind: 'idle' } - | { kind: 'sending' } - | { kind: 'sent'; text: string } - | { kind: 'error'; text: string }; - /** Compact relative age for a session's last activity: <1m, Xm, Xhr, Xd, or ''. */ function formatAge(iso: string): string { const time = new Date(iso).getTime(); @@ -70,13 +65,12 @@ export default function SidebarLiveSection({ liveSessionTmuxIds, selectedSession, onSessionSelect, + onIdleSessionOpen, }: SidebarLiveSectionProps) { // Session ids killed in this component instance — hidden immediately; the 5s // live poll is the source of truth and will drop them for real. const [killedIds, setKilledIds] = useState>(new Set()); const [killStatus, setKillStatus] = useState>(new Map()); - const [idleDrafts, setIdleDrafts] = useState>(new Map()); - const [idleSendStatus, setIdleSendStatus] = useState>(new Map()); // Reconcile row-local state with each authoritative snapshot (리뷰 반영): // ids the poll no longer reports drop their killed/confirm/error state, so a @@ -91,14 +85,6 @@ export default function SidebarLiveSection({ const next = new Map([...prev].filter(([id]) => liveSessionIds.has(id))); return next.size === prev.size ? prev : next; }); - setIdleDrafts((prev) => { - const next = new Map([...prev].filter(([id]) => liveSessionIds.has(id))); - return next.size === prev.size ? prev : next; - }); - setIdleSendStatus((prev) => { - const next = new Map([...prev].filter(([id]) => liveSessionIds.has(id))); - return next.size === prev.size ? prev : next; - }); }, [liveSessionIds]); if (liveSessionIds.size === 0) { @@ -168,95 +154,6 @@ export default function SidebarLiveSection({ } }; - const idleStatusOf = (id: string): IdleSendStatus => idleSendStatus.get(id) ?? { kind: 'idle' }; - const setIdleStatusOf = (id: string, status: IdleSendStatus) => { - setIdleSendStatus((prev) => { - const next = new Map(prev); - if (status.kind === 'idle') { - next.delete(id); - } else { - next.set(id, status); - } - return next; - }); - }; - - // First message into an idle gjc pane, straight from the web (mobile/web-only - // usage must not require a tmux hop). Same relay path as LiveRelayComposer: - // the tower types into the pane; gjc then opens its transcript and the 5s - // poll transitions this row into a real, openable session. - const sendFirstMessage = async (id: string, tmuxName: string) => { - const message = (idleDrafts.get(id) ?? '').trim(); - if (!message || idleStatusOf(id).kind === 'sending') { - return; - } - setIdleStatusOf(id, { kind: 'sending' }); - try { - const response = await api.liveSessionSend(tmuxName, message); - const body = await response.json().catch(() => null); - const data = (body?.data ?? body ?? {}) as { ok?: boolean; reachable?: boolean; queued?: boolean; detail?: string }; - if (data.reachable === false) { - setIdleStatusOf(id, { kind: 'error', text: '관제탑 미가동 — 전송 불가' }); - return; - } - if (!response.ok || data.ok === false) { - setIdleStatusOf(id, { kind: 'error', text: (typeof body?.error === 'string' && body.error) || data.detail || '전송 실패' }); - return; - } - setIdleDrafts((prev) => { - const next = new Map(prev); - next.delete(id); - return next; - }); - setIdleStatusOf(id, { - kind: 'sent', - text: data.queued ? '대기열 등록됨 — 준비되면 전달됩니다' : '전달됨 — 곧 대화로 나타납니다', - }); - } catch { - setIdleStatusOf(id, { kind: 'error', text: '전송 실패' }); - } - }; - - /** Inline first-message composer for lineage-grade idle rows. */ - const idleComposer = (id: string, tmuxName: string) => { - const status = idleStatusOf(id); - return ( -
-
- { - const value = event.target.value; - setIdleDrafts((prev) => new Map(prev).set(id, value)); - }} - onKeyDown={(event) => { - if (event.key === 'Enter' && !event.nativeEvent.isComposing) { - event.preventDefault(); - void sendFirstMessage(id, tmuxName); - } - }} - placeholder="첫 메시지 보내기… (Enter 전송)" - className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/60" - /> - -
- {(status.kind === 'sent' || status.kind === 'error') && ( -

- {status.text} -

- )} -
- ); - }; - // Shared kill affordances (matched rows + orphan rows use the same flow). const killButton = (id: string, tmuxName: string) => statusOf(id).kind === 'idle' ? ( @@ -363,33 +260,51 @@ export default function SidebarLiveSection({ // Server-synthetic row: a gjc TUI runs in this tmux session but has no // transcript yet (gjc creates it at the FIRST message) — waiting, not live. const isIdle = id.startsWith('idle-gjc:'); + const idleTarget = isIdle + ? buildIdleTarget(id, liveSessionNames, liveSessionLineage, liveSessionTmuxIds) + : null; + const rowContent = ( + <> + + + + {isIdle ? '대기' : 'LIVE'} + + + {tmuxName ?? '이름 미확인 세션'} + + + + {isIdle + ? '프롬프트 대기 중 — 클릭하면 메인 영역에서 첫 메시지를 보냅니다' + : '대화 미로딩 — 해당 프로젝트를 열면 제목이 표시됩니다'} + + + ); return (
-
- - - - {isIdle ? '대기' : 'LIVE'} - - - {tmuxName ?? '이름 미확인 세션'} - - - - {isIdle - ? '프롬프트 대기 중 — 메시지를 보내면 tmux 세션에 바로 전달됩니다' - : '대화 미로딩 — 해당 프로젝트를 열면 제목이 표시됩니다'} - -
+ {idleTarget ? ( + + ) : ( +
+ {rowContent} +
+ )} {tmuxName && liveSessionLineage.has(id) && killButton(id, tmuxName)}
- {isIdle && tmuxName && liveSessionLineage.has(id) && idleComposer(id, tmuxName)} {tmuxName && liveSessionLineage.has(id) && killStrip(id, tmuxName)}
); From 7c75bd99178c2390a51412e02c96bce81e367ec3 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:24:33 +0900 Subject: [PATCH 11/26] =?UTF-8?q?polish(sidebar):=20=ED=96=89=EB=B3=84=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=84=A4=EB=AA=85=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=ED=96=89=EC=9D=80=20=EB=B1=83?= =?UTF-8?q?=EC=A7=80+=EC=9D=B4=EB=A6=84,=20=EC=84=A4=EB=AA=85=EC=9D=80=20?= =?UTF-8?q?=ED=88=B4=ED=8C=81=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대기/미로딩 행의 서브타이틀('프롬프트 대기 중 — …', '대화 미로딩 — …') 삭제, 설명은 title 툴팁으로 이동 (반복 스캐폴딩 = UI 슬롭) - 목록 하단 면책 문구('tmux 안에서 도는 gjc 세션만 감지…') 삭제 - 대기 뷰 본문 문구 압축, '이름 미확인 세션'→'이름 미확인' --- .../main-content/view/MainContent.tsx | 2 +- .../subcomponents/SidebarLiveSection.test.tsx | 4 +- .../view/subcomponents/SidebarLiveSection.tsx | 44 +++++++++---------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index cd23ea771f..2eba9dce37 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -260,7 +260,7 @@ function MainContent({

아직 대화가 없습니다

-

아래에서 첫 메시지를 보내면 이 tmux 세션의 대화가 시작됩니다.

+

첫 메시지를 보내면 대화가 시작됩니다.

{resolvingTimedOut && (

대화를 불러오는 중… 오래 걸리면 사이드바에서 직접 열 수 있습니다 diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx index 84749e015e..062b87075c 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx @@ -91,7 +91,9 @@ test('SidebarLiveSection renders idle-gjc rows as 대기 (첫 대화 전 gjc pan assert.ok(html.includes('>flask<'), 'labels the row by tmux session name'); assert.ok(html.includes('대기'), 'idle rows carry the 대기 badge, not LIVE'); assert.ok(!html.includes('LIVE'), 'no LIVE badge for a session with no transcript'); - assert.ok(html.includes('클릭하면 메인 영역에서 첫 메시지를 보냅니다'), 'explains that input now happens in the main area'); + assert.ok(html.includes('클릭하면 메인 영역에서 첫 메시지를 보낼 수 있습니다'), 'explainer lives in the tooltip, not a per-row subtitle'); + assert.ok(!html.includes('프롬프트 대기 중'), 'no repeated subtitle scaffolding under idle rows'); + assert.ok(!html.includes('tmux 안에서 도는'), 'footer disclaimer removed'); assert.ok(html.includes('tmux 세션 flask 닫기'), 'lineage-grade idle rows keep the kill control'); assert.ok(html.includes('aria-label="flask 대기 세션 열기"'), 'lineage-grade idle rows open the main waiting view'); }); diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx index 6833cf9350..1aa12ad4dd 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx @@ -263,28 +263,23 @@ export default function SidebarLiveSection({ const idleTarget = isIdle ? buildIdleTarget(id, liveSessionNames, liveSessionLineage, liveSessionTmuxIds) : null; + // Row = badge + name only. Explanations live in the tooltip — a + // per-row subtitle repeated N times is scaffolding noise, not data. const rowContent = ( - <> - - - - {isIdle ? '대기' : 'LIVE'} - - - {tmuxName ?? '이름 미확인 세션'} - + + + + {isIdle ? '대기' : 'LIVE'} - - {isIdle - ? '프롬프트 대기 중 — 클릭하면 메인 영역에서 첫 메시지를 보냅니다' - : '대화 미로딩 — 해당 프로젝트를 열면 제목이 표시됩니다'} + + {tmuxName ?? '이름 미확인'} - + ); return (

@@ -293,13 +288,17 @@ export default function SidebarLiveSection({ ) : ( -
+
{rowContent}
)} @@ -310,9 +309,6 @@ export default function SidebarLiveSection({ ); })}
-

- tmux 안에서 도는 gjc 세션만 감지됩니다 — claude 등 다른 CLI 세션은 표시되지 않습니다. -

); } From 949090d3c112350731e2c2d61fdfb678623151e3 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:45:14 +0900 Subject: [PATCH 12/26] =?UTF-8?q?refactor(sidebar):=20=EC=9E=91=EB=8F=99?= =?UTF-8?q?=20=EC=A4=91=20=ED=83=AD=EC=97=90=EC=84=9C=20tmux=20=EB=B0=96?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=88=A8=EA=B9=80=20=E2=80=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=80=20tmux=20=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux 이름이 없는 라이브 세션(Orca·일반 터미널에서 켠 gjc)은 웹에서 전송도 종료도 불가능한 노이즈 행('이름 미확인')이라 이 목록에서 제외. liveSessionIds 에는 그대로 남아 프로젝트 목록 LIVE 뱃지와 열람-전용 배너 보호는 유지된다. 이름 폴백 렌더링('이름 미확인'·제목 대체)은 죽은 코드가 되어 함께 회수. --- .../subcomponents/SidebarLiveSection.test.tsx | 4 ++-- .../view/subcomponents/SidebarLiveSection.tsx | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx index 062b87075c..30b18d4f02 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx @@ -43,7 +43,7 @@ test('SidebarLiveSection labels rows by tmux session name, title in tooltip', () assert.ok(!html.includes('Idle conversation'), 'omits non-live sessions'); }); -test('SidebarLiveSection falls back to the conversation title when tmux name is unknown', () => { +test('SidebarLiveSection hides sessions with no tmux name (non-tmux gjc는 이 목록에서 제외)', () => { const html = renderToStaticMarkup( createElement(SidebarLiveSection, { projects: makeProjects(), @@ -56,7 +56,7 @@ test('SidebarLiveSection falls back to the conversation title when tmux name is onIdleSessionOpen: noop, }), ); - assert.ok(html.includes('Live conversation title'), 'primary label falls back to the title'); + assert.equal(html, '', 'a live session without a tmux name renders no row at all'); }); test('SidebarLiveSection renders nothing when no session is live', () => { diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx index 1aa12ad4dd..91ceb8b7b3 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx @@ -91,19 +91,28 @@ export default function SidebarLiveSection({ return null; } + // This tab is a TMUX fleet roster: sessions with no tmux name (a gjc running + // in a plain terminal — Orca, mosh, bare zsh) are pure noise here since the + // web can neither relay nor kill them. They stay in liveSessionIds so the + // read-only banner and LIVE badges elsewhere keep protecting their + // transcripts; they are only hidden from this list (사용자 결정). const rows = projects.flatMap((project) => getAllSessions(project) - .filter((session) => liveSessionIds.has(session.id) && !killedIds.has(session.id)) + .filter((session) => liveSessionIds.has(session.id) + && liveSessionNames.has(session.id) + && !killedIds.has(session.id)) .map((session) => ({ project, session })), ); // Live ids whose session isn't in any *loaded* project page (pagination) still // deserve a row — otherwise whole live sessions silently vanish from the tab // (하코 관찰: horcrux/patina 라이브가 안 보임). They render with the tmux name - // (or a placeholder) and keep the kill control; selection needs the loaded - // session object, so they are not clickable until the session list loads them. + // and keep the kill control; selection needs the loaded session object, so + // they are not clickable until the session list loads them. const matchedIds = new Set(rows.map(({ session }) => session.id)); - const orphans = [...liveSessionIds].filter((id) => !matchedIds.has(id) && !killedIds.has(id)); + const orphans = [...liveSessionIds].filter((id) => !matchedIds.has(id) + && liveSessionNames.has(id) + && !killedIds.has(id)); if (rows.length === 0 && orphans.length === 0) { return null; @@ -220,6 +229,7 @@ export default function SidebarLiveSection({ {rows.map(({ project, session }) => { const isSelected = selectedSession?.id === session.id; const title = session.summary || session.name || 'Session'; + // rows filter guarantees a tmux name; title is demoted to the tooltip. const tmuxName = liveSessionNames.get(session.id); const primary = tmuxName ?? title; const age = formatAge(getSessionTime(session)); @@ -257,6 +267,9 @@ export default function SidebarLiveSection({ })} {orphans.map((id) => { const tmuxName = liveSessionNames.get(id); + if (!tmuxName) { + return null; // orphans filter guarantees a name; TS narrowing only + } // Server-synthetic row: a gjc TUI runs in this tmux session but has no // transcript yet (gjc creates it at the FIRST message) — waiting, not live. const isIdle = id.startsWith('idle-gjc:'); @@ -277,7 +290,7 @@ export default function SidebarLiveSection({ {isIdle ? '대기' : 'LIVE'} - {tmuxName ?? '이름 미확인'} + {tmuxName} ); From c0ff477a690f5b8f4dfdcf7d70e041e5c8c29d6c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:09:59 +0900 Subject: [PATCH 13/26] =?UTF-8?q?feat(live):=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EC=A7=81=ED=9B=84=2030=EC=B4=88=20=EB=B6=80=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=8F=B4=EB=A7=81(5s=E2=86=921s)=20+=20=EC=8A=A4=ED=8F=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=82=AC=EC=A0=84=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=95=9C=EA=B5=AD=EC=96=B4=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - livePollBoost: 릴레이 전송 성공 시 30초간 live 폴링을 1초 간격으로 부스트 (대기→라이브 전환 체감 지연 축소; 평시 5초 부하 불변). 순수 헬퍼 + 테스트 5건. - useProjectsState: setInterval → 자기 스케줄링 setTimeout(동적 지연). - SidebarSpawnSession: 관제탑 NAME_RE 미러 사전검증 — 이름에 / 등 불가 문자가 있으면 요청 전에 한국어 규칙 안내, 관제탑의 영문 invalid-name 400도 동일 문구로 매핑. --- .../view/subcomponents/LiveRelayComposer.tsx | 4 ++ .../subcomponents/SidebarSpawnSession.tsx | 15 ++++-- src/hooks/useProjectsState.ts | 15 +++++- src/utils/livePollBoost.test.ts | 47 +++++++++++++++++++ src/utils/livePollBoost.ts | 40 ++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/utils/livePollBoost.test.ts create mode 100644 src/utils/livePollBoost.ts diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx index a8e26fb1d1..58a80d8a56 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { api } from '../../../../utils/api'; +import { requestLivePollBoost } from '../../../../utils/livePollBoost'; type RelayStatus = | { kind: 'idle' } @@ -45,6 +46,9 @@ export default function LiveRelayComposer({ tmuxName, tmuxId = null, model = nul } setInput(''); setStatus(data.queued ? { kind: 'queued', text: '대기열 적재됨' } : { kind: 'ok', text: '전달됨' }); + // The user is now watching for the pane's reaction (idle→live transition, + // new transcript activity) — poll fast for a short window. + requestLivePollBoost(); } catch { setStatus({ kind: 'error', text: '전송 실패' }); } diff --git a/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx b/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx index bb4897c973..7c597dedc0 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx @@ -9,6 +9,10 @@ type SpawnStatus = | { kind: 'spawning' } | { kind: 'ok'; text: string } | { kind: 'error'; text: string }; +// Mirrors the tower's NAME_RE — validating here turns the tower's English 400 +// ("invalid session name") into an actionable Korean message before any request. +const SPAWN_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const NAME_RULE_TEXT = '이름은 영문·숫자로 시작, 영문·숫자·. _ - 만 (공백·/ 불가)'; // A new gjc session is created through the control tower's /spawn (proxied by the // server). The tower validates the name + cwd and boots the tmux session; the live @@ -32,6 +36,10 @@ export default function SidebarSpawnSession() { if (!trimmedName || !trimmedCwd || status.kind === 'spawning') { return; } + if (!SPAWN_NAME_RE.test(trimmedName)) { + setStatus({ kind: 'error', text: NAME_RULE_TEXT }); + return; + } setStatus({ kind: 'spawning' }); try { const response = await api.liveSessionSpawn(trimmedName, trimmedCwd); @@ -49,13 +57,14 @@ export default function SidebarSpawnSession() { reset(); return; } + const rawError = (typeof body?.error === 'string' && body.error) || data.detail || ''; const text = data.reachable === false ? '관제탑 미가동 — 생성 불가' : data.conflict ? '같은 이름의 세션이 이미 있습니다' - : (typeof body?.error === 'string' && body.error) - || data.detail - || '세션 생성 실패'; + : rawError.includes('invalid session name') + ? NAME_RULE_TEXT + : rawError || '세션 생성 실패'; setStatus({ kind: 'error', text }); } catch { setStatus({ kind: 'error', text: '세션 생성 실패' }); diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 7e5ffd8fb8..ffb5509889 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NavigateFunction } from 'react-router-dom'; import { api } from '../utils/api'; +import { nextLivePollDelay } from '../utils/livePollBoost'; import type { ServerEvent } from '../contexts/WebSocketContext'; import type { AppTab, @@ -460,11 +461,21 @@ export function useProjectsState({ // for read-only protection). } }; + // Self-scheduling instead of setInterval: the delay shrinks to ~1s for a + // short window after a relay send (see livePollBoost) so the idle→live + // transition is picked up quickly, then returns to the 5s baseline. + let timer: ReturnType | undefined; + const schedule = () => { + timer = setTimeout(async () => { + await poll(); + if (!cancelled) schedule(); + }, nextLivePollDelay()); + }; void poll(); - const timer = setInterval(poll, 5000); + schedule(); return () => { cancelled = true; - clearInterval(timer); + if (timer !== undefined) clearTimeout(timer); }; }, []); diff --git a/src/utils/livePollBoost.test.ts b/src/utils/livePollBoost.test.ts new file mode 100644 index 0000000000..fcce899895 --- /dev/null +++ b/src/utils/livePollBoost.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; + +import { + LIVE_POLL_BASE_MS, + LIVE_POLL_BOOST_MS, + LIVE_POLL_BOOST_WINDOW_MS, + nextLivePollDelay, + requestLivePollBoost, + resetLivePollBoost, +} from './livePollBoost'; + +describe('livePollBoost', () => { + beforeEach(() => { + resetLivePollBoost(); + }); + + it('returns the base delay when no boost was requested', () => { + assert.equal(nextLivePollDelay(1_000), LIVE_POLL_BASE_MS); + }); + + it('returns the boost delay inside the boost window', () => { + requestLivePollBoost(10_000); + assert.equal(nextLivePollDelay(10_000), LIVE_POLL_BOOST_MS); + assert.equal(nextLivePollDelay(10_000 + LIVE_POLL_BOOST_WINDOW_MS - 1), LIVE_POLL_BOOST_MS); + }); + + it('falls back to the base delay once the window expires', () => { + requestLivePollBoost(10_000); + assert.equal(nextLivePollDelay(10_000 + LIVE_POLL_BOOST_WINDOW_MS), LIVE_POLL_BASE_MS); + }); + + it('a later request extends the window; an earlier one never shortens it', () => { + requestLivePollBoost(10_000); + requestLivePollBoost(20_000); + assert.equal(nextLivePollDelay(20_000 + LIVE_POLL_BOOST_WINDOW_MS - 1), LIVE_POLL_BOOST_MS); + // Out-of-order (older timestamp) request must not shrink the active window. + requestLivePollBoost(5_000); + assert.equal(nextLivePollDelay(20_000 + LIVE_POLL_BOOST_WINDOW_MS - 1), LIVE_POLL_BOOST_MS); + }); + + it('custom window sizes are honored', () => { + requestLivePollBoost(0, 2_000); + assert.equal(nextLivePollDelay(1_999), LIVE_POLL_BOOST_MS); + assert.equal(nextLivePollDelay(2_000), LIVE_POLL_BASE_MS); + }); +}); diff --git a/src/utils/livePollBoost.ts b/src/utils/livePollBoost.ts new file mode 100644 index 0000000000..0cb3352a52 --- /dev/null +++ b/src/utils/livePollBoost.ts @@ -0,0 +1,40 @@ +/** + * Live-poll boost window. + * + * The sidebar's live-session poll runs every LIVE_POLL_BASE_MS. Right after a + * relay send into a tmux gjc pane (first message from the waiting view, or a + * follow-up from the live view) the user is watching for the idle→live + * transition, which only happens on the next poll tick. Requesting a boost + * shrinks the poll delay to LIVE_POLL_BOOST_MS for a short window so the + * transition lands in ~1-2s instead of up to 5s+ — without raising the + * steady-state polling load. + * + * Module-level on purpose: the poll loop (useProjectsState) and the composers + * (LiveRelayComposer) live in unrelated trees; threading a callback through + * every layer for a UX hint would be ceremony. Worst case on a stale boost is + * a few extra polls for 30s. + */ + +export const LIVE_POLL_BASE_MS = 5000; +export const LIVE_POLL_BOOST_MS = 1000; +export const LIVE_POLL_BOOST_WINDOW_MS = 30_000; + +let boostUntil = 0; + +/** Extends (never shortens) the boost window from `now`. */ +export function requestLivePollBoost( + now: number = Date.now(), + windowMs: number = LIVE_POLL_BOOST_WINDOW_MS, +): void { + boostUntil = Math.max(boostUntil, now + windowMs); +} + +/** Delay until the next live poll tick, honoring an active boost window. */ +export function nextLivePollDelay(now: number = Date.now()): number { + return now < boostUntil ? LIVE_POLL_BOOST_MS : LIVE_POLL_BASE_MS; +} + +/** Test helper — clears any active boost window. */ +export function resetLivePollBoost(): void { + boostUntil = 0; +} From 7c7fdaf8b1dca17533544e2b5388f543e291bcc0 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:31:40 +0900 Subject: [PATCH 14/26] =?UTF-8?q?feat(spawn):=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1?= =?UTF-8?q?=EC=97=90=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=A3=A8=ED=8A=B8=20=EA=B8=B0=EB=B3=B8=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20(scope=3Dspawn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dir-suggestions에 scope=spawn 추가: TOWER_ALLOWED_ROOTS(관제탑과 동일 계약)의 자식 폴더를 홈보다 먼저 제안, 중복 제거, 상한 20 유지. 반환 문자열은 tower resolveSpawnCwd(홈→허용루트)가 그대로 받는 상대 이름. - 파일 패널은 기존 홈 전용 스코프 유지(home+제안 join 계약 보존). - HomeDirInput scope prop: spawn 스코프는 빈 입력에서도 기본 목록 노출. - 스폰 폼 placeholder 갱신. 서버 테스트 2건 추가(루트 우선/중복/traversal/누락 루트 강등). --- server/modules/providers/provider.routes.ts | 12 ++-- .../providers/services/home-dirs.service.ts | 72 +++++++++++++++---- .../providers/tests/home-dirs.service.test.ts | 37 ++++++++++ .../subcomponents/SidebarSpawnSession.tsx | 3 +- src/shared/view/HomeDirInput.tsx | 13 ++-- src/utils/api.js | 7 +- 6 files changed, 120 insertions(+), 24 deletions(-) diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 27375bcdc6..730d29a3c6 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -9,7 +9,7 @@ import { sessionConversationsSearchService } from '@/modules/providers/services/ import { sessionsService } from '@/modules/providers/services/sessions.service.js'; import { getLiveGjcSessions, IDLE_GJC_ID_PREFIX } from '@/modules/providers/services/live-sessions.service.js'; import { getExternalCliSessions } from '@/modules/providers/services/external-cli-sessions.service.js'; -import { getHomeDir, getHomeDirSuggestions } from '@/modules/providers/services/home-dirs.service.js'; +import { getHomeDir, getHomeDirSuggestions, getSpawnDirSuggestions } from '@/modules/providers/services/home-dirs.service.js'; import { isValidTmuxName, sendToLiveSession, isValidSpawnName, spawnLiveSession, killLiveSession } from '@/modules/providers/services/live-send.service.js'; import type { LLMProvider, @@ -600,10 +600,14 @@ router.get( router.get( '/fs/dir-suggestions', asyncHandler(async (req: Request, res: Response) => { - // Home-relative directory autocomplete (spawn form cwd + files panel root). - // Read-only readdir under $HOME, traversal-guarded in the service. + // Directory autocomplete. Default scope stays $HOME-relative (files panel + // joins home + suggestion, so extra roots would break it); scope=spawn adds + // the tower's TOWER_ALLOWED_ROOTS children first — the strings it returns + // are exactly what the tower's /spawn cwd resolution accepts. const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : ''; - const suggestions = await getHomeDirSuggestions(prefix); + const suggestions = req.query.scope === 'spawn' + ? await getSpawnDirSuggestions(prefix) + : await getHomeDirSuggestions(prefix); res.json(createApiSuccessResponse({ home: getHomeDir(), suggestions })); }), ); diff --git a/server/modules/providers/services/home-dirs.service.ts b/server/modules/providers/services/home-dirs.service.ts index bcee91bc30..63e3888db8 100644 --- a/server/modules/providers/services/home-dirs.service.ts +++ b/server/modules/providers/services/home-dirs.service.ts @@ -82,23 +82,16 @@ function isUnderAnyRoot(target: string, roots: string[]): boolean { return roots.some((root) => target === root || target.startsWith(`${root}${path.sep}`)); } -/** - * Lists directory suggestions for a home-relative prefix. [] on any failure - * (missing dir, permission, traversal or symlink escape). - * `homeDir` is parameterized for tests; production uses $HOME. - */ -export async function getHomeDirSuggestions(prefix: string, homeDir: string = os.homedir()): Promise { - if (prefix.includes('\0') || prefix.startsWith('/') || prefix.length > 512) { - return []; - } +/** Shared core: suggestions for `prefix` resolved under `base`, contained in `allowedRoots`. */ +async function suggestUnderBase(prefix: string, base: string, allowedRoots: string[]): Promise { const { dirPart, fragment } = splitPrefix(prefix); - const target = path.resolve(homeDir, dirPart); + const target = path.resolve(base, dirPart); // Lexical guard first (cheap reject of ../ traversal)… - if (target !== homeDir && !target.startsWith(`${homeDir}${path.sep}`)) { + if (target !== base && !target.startsWith(`${base}${path.sep}`)) { return []; } // …then real-path containment so symlinks cannot escape the allowed roots. - const [targetReal, allowedRoots] = await Promise.all([safeRealpath(target), resolveAllowedRoots(homeDir)]); + const targetReal = await safeRealpath(target); if (!targetReal || allowedRoots.length === 0 || !isUnderAnyRoot(targetReal, allowedRoots)) { return []; } @@ -113,3 +106,58 @@ export async function getHomeDirSuggestions(prefix: string, homeDir: string = os return []; } } + +/** + * Lists directory suggestions for a home-relative prefix. [] on any failure + * (missing dir, permission, traversal or symlink escape). + * `homeDir` is parameterized for tests; production uses $HOME. + */ +export async function getHomeDirSuggestions(prefix: string, homeDir: string = os.homedir()): Promise { + if (prefix.includes('\0') || prefix.startsWith('/') || prefix.length > 512) { + return []; + } + return suggestUnderBase(prefix, homeDir, await resolveAllowedRoots(homeDir)); +} + +/** TOWER_ALLOWED_ROOTS csv (same contract as the tower) → absolute roots. */ +export function parseExtraSpawnRoots(raw: string | undefined): string[] { + return (raw ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => path.isAbsolute(entry)); +} + +/** + * Spawn-scope suggestions: extra spawn roots (the tower's TOWER_ALLOWED_ROOTS, + * e.g. the /Volumes workspace) come FIRST, then $HOME — the workspace is where + * new sessions usually live. Suggestions stay bare relative names on purpose: + * the tower's resolveSpawnCwd resolves them $HOME-first then per allowed root, + * so the picked string is exactly what /spawn accepts. On a rare name collision + * (same child under $HOME and a root) the tower's $HOME-first rule wins. + */ +export async function getSpawnDirSuggestions( + prefix: string, + homeDir: string = os.homedir(), + extraRoots: string[] = parseExtraSpawnRoots(process.env.TOWER_ALLOWED_ROOTS), +): Promise { + if (prefix.includes('\0') || prefix.startsWith('/') || prefix.length > 512) { + return []; + } + const lanes: string[][] = []; + for (const root of extraRoots) { + const rootReal = await safeRealpath(root); + lanes.push(rootReal ? await suggestUnderBase(prefix, rootReal, [rootReal]) : []); + } + lanes.push(await suggestUnderBase(prefix, homeDir, await resolveAllowedRoots(homeDir))); + const seen = new Set(); + const merged: string[] = []; + for (const lane of lanes) { + for (const suggestion of lane) { + if (!seen.has(suggestion)) { + seen.add(suggestion); + merged.push(suggestion); + } + } + } + return merged.slice(0, MAX_DIR_SUGGESTIONS); +} diff --git a/server/modules/providers/tests/home-dirs.service.test.ts b/server/modules/providers/tests/home-dirs.service.test.ts index ede066ba70..c0a3971978 100644 --- a/server/modules/providers/tests/home-dirs.service.test.ts +++ b/server/modules/providers/tests/home-dirs.service.test.ts @@ -7,6 +7,8 @@ import test from 'node:test'; import { filterDirSuggestions, getHomeDirSuggestions, + getSpawnDirSuggestions, + parseExtraSpawnRoots, splitPrefix, } from '@/modules/providers/services/home-dirs.service.js'; @@ -61,3 +63,38 @@ test('getHomeDirSuggestions: realpath containment — deep symlink escape return await rm(outside, { recursive: true, force: true }); } }); + +test('parseExtraSpawnRoots keeps only absolute csv entries', () => { + assert.deepEqual(parseExtraSpawnRoots(undefined), []); + assert.deepEqual(parseExtraSpawnRoots(' /Volumes/Data/Dev Workspace , relative/path , '), ['/Volumes/Data/Dev Workspace']); +}); + +test('getSpawnDirSuggestions: extra roots first, home after, deduped; empty prefix lists defaults', async () => { + const home = await mkdtemp(path.join(os.tmpdir(), 'spawn-home-')); + const root = await mkdtemp(path.join(os.tmpdir(), 'spawn root-')); // space: 실제 워크스페이스 경로 형태 + try { + await mkdir(path.join(home, 'zeta')); + await mkdir(path.join(home, 'shared')); + await mkdir(path.join(root, 'aegis-alpha')); + await mkdir(path.join(root, 'shared')); + await mkdir(path.join(root, 'aegis-alpha', 'sub')); + + // Empty prefix = default list: workspace children first, then home's. + assert.deepEqual( + await getSpawnDirSuggestions('', home, [root]), + ['aegis-alpha', 'shared', 'zeta'], + ); + // Fragment matching hits the workspace root even when home has no match. + assert.deepEqual(await getSpawnDirSuggestions('aeg', home, [root]), ['aegis-alpha']); + // Nested listing under a workspace child works. + assert.deepEqual(await getSpawnDirSuggestions('aegis-alpha/', home, [root]), ['aegis-alpha/sub']); + // Traversal/absolute prefixes stay rejected in spawn scope too. + assert.deepEqual(await getSpawnDirSuggestions('../x', home, [root]), []); + assert.deepEqual(await getSpawnDirSuggestions('/etc/', home, [root]), []); + // A missing extra root degrades to home-only. + assert.deepEqual(await getSpawnDirSuggestions('sh', home, [path.join(root, 'nope')]), ['shared']); + } finally { + await rm(home, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx b/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx index 7c597dedc0..1df91581ac 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx @@ -97,7 +97,8 @@ export default function SidebarSpawnSession() { value={cwd} onChange={setCwd} onSubmit={() => void spawn()} - placeholder="작업 폴더 (홈 하위, 예: workspace/my-proj)" + placeholder="작업 폴더 (예: aegis-alpha, workspace/my-proj)" + scope="spawn" /> {status.kind !== 'idle' && status.kind !== 'spawning' && (

diff --git a/src/shared/view/HomeDirInput.tsx b/src/shared/view/HomeDirInput.tsx index 99c2a0d665..6688ff6717 100644 --- a/src/shared/view/HomeDirInput.tsx +++ b/src/shared/view/HomeDirInput.tsx @@ -8,6 +8,8 @@ type HomeDirInputProps = { onSubmit?: () => void; placeholder?: string; className?: string; + /** 'spawn' merges the tower's allowed spawn roots (workspace-first). */ + scope?: 'home' | 'spawn'; }; const DEBOUNCE_MS = 200; @@ -18,7 +20,7 @@ const DEBOUNCE_MS = 200; * click or Tab (first match) completes. Best-effort — endpoint errors just * hide the dropdown. */ -export default function HomeDirInput({ value, onChange, onSubmit, placeholder, className }: HomeDirInputProps) { +export default function HomeDirInput({ value, onChange, onSubmit, placeholder, className, scope = 'home' }: HomeDirInputProps) { const [suggestions, setSuggestions] = useState([]); const [open, setOpen] = useState(false); const debounceRef = useRef | null>(null); @@ -28,14 +30,17 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c if (debounceRef.current) { clearTimeout(debounceRef.current); } - if (!value.trim()) { + // An empty spawn input still fetches: the dropdown then doubles as a + // "pick a project" default list (workspace roots first). The home scope + // keeps its old behavior — no dropdown until something is typed. + if (!value.trim() && scope !== 'spawn') { setSuggestions([]); return undefined; } const seq = ++requestSeqRef.current; debounceRef.current = setTimeout(async () => { try { - const response = await api.dirSuggestions(value.trim()); + const response = await api.dirSuggestions(value.trim(), scope === 'spawn' ? 'spawn' : null); if (!response.ok) return; const body = await response.json(); const list: string[] = body?.data?.suggestions ?? []; @@ -52,7 +57,7 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c clearTimeout(debounceRef.current); } }; - }, [value]); + }, [value, scope]); const pick = (suggestion: string) => { onChange(`${suggestion}/`); diff --git a/src/utils/api.js b/src/utils/api.js index 021f45aacb..d673eb6307 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -100,9 +100,10 @@ export const api = { }), // External CLI (claude/codex) tmux sessions for the terminal-attach lane. externalSessions: () => authenticatedFetch('/api/providers/sessions/external'), - // Home-relative directory autocomplete ({ home, suggestions }). - dirSuggestions: (prefix) => - authenticatedFetch(`/api/providers/fs/dir-suggestions?prefix=${encodeURIComponent(prefix)}`), + // Directory autocomplete ({ home, suggestions }). Default scope is + // home-relative; scope='spawn' merges the tower's allowed spawn roots first. + dirSuggestions: (prefix, /** @type {string | null} */ scope = null) => + authenticatedFetch(`/api/providers/fs/dir-suggestions?prefix=${encodeURIComponent(prefix)}${scope ? `&scope=${encodeURIComponent(scope)}` : ''}`), projectSessions: (projectId, { limit = 20, offset = 0 } = {}) => { const params = new URLSearchParams(); params.set('limit', String(limit)); From 97e1a70fea1fad1086df106d0c3c8175d1f9928b Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:55:35 +0900 Subject: [PATCH 15/26] =?UTF-8?q?fix(security):=20send/kill=EC=9D=98=20tmu?= =?UTF-8?q?x=20=EC=84=B8=EB=8C=80=20=ED=86=A0=ED=81=B0($N)=20=ED=95=84?= =?UTF-8?q?=EC=88=98=ED=99=94=20=E2=80=94=20=EC=83=9D=EB=9E=B5=20=EC=8B=9C?= =?UTF-8?q?=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 HIGH: tmuxId를 빼면 세대 비교가 통째로 건너뛰어져 같은 이름으로 재생성된 다른 세션에 전송/종료가 가능했다. 서버는 누락·비정형 토큰을 400으로 거부하고, 클라이언트(릴레이 컴포저·kill 버튼)는 토큰이 없으면 로컬에서 fail-closed로 거부한다. --- server/modules/providers/provider.routes.ts | 18 ++++++++++-------- .../view/subcomponents/LiveRelayComposer.tsx | 6 ++++++ .../view/subcomponents/SidebarLiveSection.tsx | 9 ++++++++- src/utils/api.js | 17 +++++++++-------- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 730d29a3c6..bc9e307c73 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -628,7 +628,7 @@ router.get( */ const TMUX_ID_RE = /^\$\d+$/; -async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string | null): Promise { +async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string): Promise { const live = await getLiveGjcSessions(); const matches = live.filter((session) => session.tmuxName === tmuxName && session.claim === 'lineage'); if (matches.length === 0) { @@ -637,7 +637,7 @@ async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string | null): statusCode: 403, }); } - if (tmuxId !== null && !matches.some((session) => session.tmuxId === tmuxId)) { + if (!matches.some((session) => session.tmuxId === tmuxId)) { throw new AppError('tmux 세션이 그 사이 교체되었습니다 — 같은 이름의 다른 세션입니다. 목록을 새로고침한 뒤 다시 시도하세요.', { code: 'TMUX_GENERATION_MISMATCH', statusCode: 409, @@ -645,15 +645,17 @@ async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string | null): } } -/** Optional `$N` generation token from the request body; malformed values are rejected. */ -function readTmuxIdParam(value: unknown): string | null { - if (value === undefined || value === null || value === '') { - return null; - } +/** + * REQUIRED `$N` generation token from the request body. A missing token is a + * 400, not a skipped check — otherwise any authenticated caller could omit it + * and bypass the same-name replacement guard entirely (리뷰 HIGH: fail-closed + * means the generation comparison must be unavoidable). + */ +function readTmuxIdParam(value: unknown): string { if (typeof value === 'string' && TMUX_ID_RE.test(value)) { return value; } - throw new AppError('tmuxId must look like "$".', { code: 'INVALID_TMUX_ID', statusCode: 400 }); + throw new AppError('tmuxId is required and must look like "$".', { code: 'INVALID_TMUX_ID', statusCode: 400 }); } router.post( diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx index 58a80d8a56..a95dabf7b6 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx @@ -29,6 +29,12 @@ export default function LiveRelayComposer({ tmuxName, tmuxId = null, model = nul if (!message || status.kind === 'sending') { return; } + // Server contract: the $N generation token is required (fail-closed). No + // token means we cannot prove which same-named session receives the text. + if (!tmuxId) { + setStatus({ kind: 'error', text: '세션 세대 정보 미확인 — 목록 갱신 후 다시 시도' }); + return; + } setStatus({ kind: 'sending' }); try { const response = await api.liveSessionSend(tmuxName, message, tmuxId); diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx index 91ceb8b7b3..c1b0256b54 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.tsx @@ -132,9 +132,16 @@ export default function SidebarLiveSection({ }; const kill = async (sessionId: string, tmuxName: string) => { + // The server requires the $N generation token (fail-closed). Without one + // we cannot prove WHICH same-named session would die — refuse locally. + const tmuxId = liveSessionTmuxIds.get(sessionId) ?? null; + if (!tmuxId) { + setStatusOf(sessionId, { kind: 'error', text: '세션 세대 정보 미확인 — 목록 갱신 후 다시 시도' }); + return; + } setStatusOf(sessionId, { kind: 'killing' }); try { - const response = await api.liveSessionKill(tmuxName, liveSessionTmuxIds.get(sessionId) ?? null); + const response = await api.liveSessionKill(tmuxName, tmuxId); const body = await response.json().catch(() => null); const data = (body?.data ?? body ?? {}) as { ok?: boolean; diff --git a/src/utils/api.js b/src/utils/api.js index d673eb6307..ea9fe5b4ce 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -69,17 +69,18 @@ export const api = { // Session ids currently live in a tmux gjc pane (tmux+lsof; [] when no tmux). liveSessions: () => authenticatedFetch('/api/providers/sessions/live'), // Relay a message into a live tmux gjc session via the control tower (POST /send). - // `tmuxId` ($N generation token) makes the server refuse a same-named session - // that replaced the one this client saw. + // `tmuxId` ($N generation token) is REQUIRED by the server: it refuses a + // same-named session that replaced the one this client saw. Callers must + // fail closed (disable/deny) when they have no token instead of omitting it. /** * @param {string} tmuxName * @param {string} message - * @param {string | null} [tmuxId] + * @param {string} tmuxId */ - liveSessionSend: (tmuxName, message, tmuxId = null) => + liveSessionSend: (tmuxName, message, tmuxId) => authenticatedFetch('/api/providers/sessions/live/send', { method: 'POST', - body: JSON.stringify(tmuxId ? { tmuxName, tmuxId, message } : { tmuxName, message }), + body: JSON.stringify({ tmuxName, tmuxId, message }), }), // Spawn a new tmux gjc session via the control tower (POST /spawn). liveSessionSpawn: (name, cwd) => @@ -91,12 +92,12 @@ export const api = { // the fleet-lifecycle authority — protected sessions are refused there. /** * @param {string} tmuxName - * @param {string | null} [tmuxId] + * @param {string} tmuxId */ - liveSessionKill: (tmuxName, tmuxId = null) => + liveSessionKill: (tmuxName, tmuxId) => authenticatedFetch('/api/providers/sessions/live/kill', { method: 'POST', - body: JSON.stringify(tmuxId ? { tmuxName, tmuxId } : { tmuxName }), + body: JSON.stringify({ tmuxName, tmuxId }), }), // External CLI (claude/codex) tmux sessions for the terminal-attach lane. externalSessions: () => authenticatedFetch('/api/providers/sessions/external'), From 0abfd15d56156191cd7c83745321c3a6278f060b Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:55:35 +0900 Subject: [PATCH 16/26] =?UTF-8?q?fix(server):=20gjc=20argv=20=EC=A6=9D?= =?UTF-8?q?=EA=B1=B0=20=ED=98=91=EC=86=8C=ED=99=94=20+=20transcript=20lsof?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=80=EC=A6=9D=EB=90=9C=20pid=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=ED=95=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 HIGH: 임의 위치의 /.../gjc 토큰(vim /tmp/gjc 등)이 lineage 승격 증거로 오인됐다 — argv[0]=gjc 또는 bun/node의 argv[1] 스크립트만 인정. 리뷰 MEDIUM: 전역 'lsof -c bun -c node'가 Node 많은 호스트에서 4MiB/4s 가드를 자초해 lane 전체를 떨어뜨릴 수 있었다 — ps 스냅샷 1회로 gjc pid+후손을 구한 뒤 'lsof -a -p '로 범위 한정. 음성/공백경로/순환 ppid 테스트 추가. --- .../services/external-cli-sessions.service.ts | 60 +++++--- .../services/live-sessions.service.ts | 139 ++++++++++++------ .../external-cli-sessions.service.test.ts | 21 ++- .../tests/live-sessions.service.test.ts | 27 ++++ 4 files changed, 176 insertions(+), 71 deletions(-) diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index fb9e1cff12..4b464fe3fe 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -77,31 +77,51 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; /** * Pids whose COMMAND LINE identifies gjc, from `ps -eo pid=,args=` output. * comm alone cannot see script installs — macOS 실측: gjc runs as - * `bun /…/.bun/bin/gjc`, so comm is `bun`. Argv is the portable evidence: - * a pid counts when argv[0]'s basename is 'gjc', or any PATH-looking token - * ('/' 포함) has basename 'gjc'/'gjc.js' (covers `bun /…/gjc`, - * `node /…/gjc.js`, wrapper scripts). Bare non-argv0 'gjc' words - * (e.g. `grep gjc`) are deliberately NOT evidence. + * `bun /…/.bun/bin/gjc`, so comm is `bun`. A pid counts only when argv[0]'s + * basename is `gjc`, or argv[0] is bun/node and its first argument's basename + * is `gjc`/`gjc.js`. Later command-line tokens are never evidence. */ +function hasGjcArgvEvidence(commandLine: string): boolean { + const firstSpace = commandLine.search(/\s/); + const argv0 = firstSpace < 0 ? commandLine : commandLine.slice(0, firstSpace); + const basename = (value: string) => value.slice(value.lastIndexOf('/') + 1); + + if (basename(argv0) === 'gjc' || /^\/.*\/gjc(?=\s|$)/.test(commandLine)) { + return true; + } + + let runtime = basename(argv0); + let firstArgument = firstSpace < 0 ? '' : commandLine.slice(firstSpace).trimStart(); + if (runtime !== 'bun' && runtime !== 'node') { + // `ps args` flattens paths with spaces. Keep the runtime at argv[0] by + // accepting only a command line that starts with its absolute path. + const spacedRuntime = /^(\/.*\/(bun|node))\s+(.+)$/.exec(commandLine); + if (!spacedRuntime) { + return false; + } + runtime = spacedRuntime[2]; + firstArgument = spacedRuntime[3]; + } + + if ((runtime !== 'bun' && runtime !== 'node') || firstArgument.startsWith('-')) { + return false; + } + + const firstToken = firstArgument.split(/\s+/, 1)[0]; + if (basename(firstToken) === 'gjc' || basename(firstToken) === 'gjc.js') { + return true; + } + + // macOS `ps args` removes argv boundaries from space-containing path names. + // This remains anchored immediately after the bun/node executable. + return firstArgument.startsWith('/') && /^\/.*\/gjc(?:\.js)?(?=\s|$)/.test(firstArgument); +} + export function parseGjcPidsFromPsArgs(output: string): Set { const pids = new Set(); for (const raw of output.split(/\r?\n/)) { const match = /^\s*(\d+)\s+(.+)$/.exec(raw); - if (!match) { - continue; - } - const tokens = match[2].trim().split(/\s+/); - const isGjc = tokens.some((rawToken, index) => { - // Shell wrapper argv flattens `sh -c "…; /path/gjc; rc=$?"` into tokens - // like `/path/gjc;` — strip trailing shell punctuation before matching. - const token = rawToken.replace(/[;,)&|]+$/, ''); - const base = token.slice(token.lastIndexOf('/') + 1); - if (index === 0) { - return base === 'gjc'; - } - return token.includes('/') && (base === 'gjc' || base === 'gjc.js'); - }); - if (isGjc) { + if (match && hasGjcArgvEvidence(match[2].trim())) { pids.add(Number.parseInt(match[1], 10)); } } diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index 303fe3883e..21fe697511 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import { open, realpath, stat } from 'node:fs/promises'; -import { parseGjcPidsFromPsArgs, parsePsTree } from './external-cli-sessions.service.js'; +import { parseGjcPidsFromPsArgs } from './external-cli-sessions.service.js'; /** * Live gjc session detection + tmux-session naming. @@ -9,11 +9,11 @@ import { parseGjcPidsFromPsArgs, parsePsTree } from './external-cli-sessions.ser * A gjc session is "live" when a running gjc process has its transcript file open. * For the "작동 중" fleet view we also map each live session id → the tmux session * NAME it runs in (omg / stock / flask / …), by PROCESS LINEAGE: - * - lsof (-c gjc/bun/node -F pn) → {session-id uuid, holder pid} for open session - * files (macOS: gjc runs under its runtime wrapper, so comm is `bun`/`node` — - * `-c gjc` alone finds nothing there; the session-file path is the real filter) - * - ps -eo pid=,ppid= → one snapshot for the holder's ancestor pid chain - * (portable: macOS has no /proc) + * - ps -eo pid=,ppid=,args= → gjc argv evidence + descendants → lsof only + * for candidate pids (macOS: gjc runs under its runtime wrapper, so `bun` + * and `node` descendants are included without scanning those runtimes globally) + * - the same ps snapshot → holder ancestor chains (portable: macOS has no + * /proc) * - tmux list-panes → {session_name, pane_pid, pane cwd (realpath)} * - a pane_pid found in the holder's ancestor chain → that pane's tmux name (0 ambiguity) * - cwd equality is a FALLBACK only (many-to-many when panes share a cwd) @@ -317,6 +317,62 @@ async function safeRealpath(target: string): Promise { } } +export type PsProcessRecord = { pid: number; ppid: number; args: string }; + +/** Parses `ps -eo pid=,ppid=,args=` rows while preserving command-line spaces. */ +export function parsePsProcessRecords(output: string): PsProcessRecord[] { + const records: PsProcessRecord[] = []; + for (const raw of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(raw); + if (match) { + records.push({ + pid: Number.parseInt(match[1], 10), + ppid: Number.parseInt(match[2], 10), + args: match[3], + }); + } + } + return records; +} + +/** Includes the seed pids and every descendant from one ps snapshot (cycle guarded). */ +export function expandProcessDescendants( + seedPids: ReadonlySet, + processes: ReadonlyArray>, +): Set { + const children = new Map(); + for (const { pid, ppid } of processes) { + if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid)) { + continue; + } + const siblings = children.get(ppid); + if (siblings) { + siblings.push(pid); + } else { + children.set(ppid, [pid]); + } + } + + const descendants = new Set(); + const queue = [...seedPids]; + for (let index = 0; index < queue.length; index += 1) { + const pid = queue[index]; + if (!Number.isSafeInteger(pid) || pid <= 0 || descendants.has(pid)) { + continue; + } + descendants.add(pid); + for (const child of children.get(pid) ?? []) { + queue.push(child); + } + } + return descendants; +} +function argsCommandBasename(args: string): string { + const firstSpace = args.search(/\s/); + const argv0 = firstSpace < 0 ? args : args.slice(0, firstSpace); + return argv0.slice(argv0.lastIndexOf('/') + 1); +} + /** Parses `ps -eo pid=,ppid=` output into a child pid → parent pid map. */ export function parsePidParents(output: string): Map { const parents = new Map(); @@ -514,27 +570,35 @@ async function scanLiveGjcSessions(): Promise { panes.push({ name: pane.name, sid: pane.sid, pid: pane.pid, cwd: (await safeRealpath(pane.cwd)) ?? pane.cwd }); } - // Transcript lane (lsof). A transient lsof failure must not blank the whole - // fleet: fall through with zero transcript-backed sessions and let the idle - // lane still report gjc panes. - let lsofOutput = ''; + // One ps snapshot identifies gjc process trees and supplies holder ancestry. + // A ps failure degrades both dependent lanes to empty; no broad lsof scan is + // allowed when gjc ownership cannot be established. + let psRecords: PsProcessRecord[] = []; + let gjcPids = new Set(); try { - // -c matches the process COMM: a Linux gjc binary is `gjc`, but a script - // install runs under its runtime (macOS 실측: comm은 `bun`) — cover both. - // SESSION_FILE_RE below is the authoritative filter; -c only bounds cost. - lsofOutput = await runCommand('lsof', ['-c', 'gjc', '-c', 'bun', '-c', 'node', '-F', 'pn']); + const psOutput = await runCommand('ps', ['-eo', 'pid=,ppid=,args=']); + psRecords = parsePsProcessRecords(psOutput); + gjcPids = parseGjcPidsFromPsArgs(psOutput); } catch { - lsofOutput = ''; + // fall through with no process evidence + } + + // Transcript lane (lsof). Restrict collection to gjc and its descendants: + // a global bun/node scan can exceed the subprocess resource guard. + let lsofOutput = ''; + const candidatePids = [...expandProcessDescendants(gjcPids, psRecords)]; + if (candidatePids.length > 0) { + try { + lsofOutput = await runCommand('lsof', ['-a', '-p', candidatePids.join(','), '-F', 'pn']); + } catch { + // fall through with no transcript-backed sessions + } } const holders = parseLsofPidSessions(lsofOutput); - // One ps snapshot for ancestor chains — /proc//stat does not exist on - // macOS. Best-effort: an empty map only disables lineage, cwd fallback stays. - let parents: Map = new Map(); - try { - parents = parsePidParents(await runCommand('ps', ['-eo', 'pid=,ppid='])); - } catch { - // fall through with an empty map + const parents = new Map(); + for (const { pid, ppid } of psRecords) { + parents.set(pid, ppid); } // Holder cwds for the label-only fallback — /proc//cwd does not exist on @@ -564,27 +628,16 @@ async function scanLiveGjcSessions(): Promise { const sessionPaths = extractSessionPathsFromLsof(lsofOutput); const named = computeLiveSessions({ tmuxPresent: true, panes, sessions }); - // gjc panes with no open transcript (first message pending). Best-effort: - // a ps failure only hides idle rows, never the lsof-backed ones. Exclusion - // is LINEAGE names only — a cwd label must not hide a subtree-proven pane. - let idlePanes: Array<{ name: string; sid: string }> = []; - try { - const [psOutput, psArgsOutput] = await Promise.all([ - runCommand('ps', ['-eo', 'pid,ppid,comm']), - // argv snapshot: comm cannot see script installs (macOS: gjc → `bun`). - runCommand('ps', ['-eo', 'pid=,args=']), - ]); - idlePanes = findIdleGjcTmuxSessions({ - panes, - procs: parsePsTree(psOutput), - gjcPids: parseGjcPidsFromPsArgs(psArgsOutput), - excludedNames: new Set( - named.flatMap((session) => (session.claim === 'lineage' && session.tmuxName ? [session.tmuxName] : [])), - ), - }); - } catch { - // ignore — the idle lane is additive - } + // gjc panes with no open transcript (first message pending). Reuse the + // process snapshot above; a ps failure simply leaves this additive lane empty. + const idlePanes = findIdleGjcTmuxSessions({ + panes, + procs: psRecords.map(({ pid, ppid, args }) => ({ pid, ppid, comm: argsCommandBasename(args) })), + gjcPids, + excludedNames: new Set( + named.flatMap((session) => (session.claim === 'lineage' && session.tmuxName ? [session.tmuxName] : [])), + ), + }); // Enrich with the current model (last model_change in the transcript tail). const enriched = await Promise.all( diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index d21ac9550e..241180f0ef 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -167,16 +167,21 @@ test('classifyExternalSessions: sorted by tmux name for stable rendering', () => assert.deepEqual(result.map((s) => s.tmuxName), ['alpha', 'zeta']); }); -test('parseGjcPidsFromPsArgs: argv 증거로 gjc pid 식별 (macOS 실측 shapes)', () => { +test('parseGjcPidsFromPsArgs accepts only argv0 and the bun/node first argument', () => { const pids = parseGjcPidsFromPsArgs([ - '89726 bun /Users/dev/.bun/bin/gjc', // macOS script install - ' 100 gjc --no-session', // Linux native binary (argv0) - ' 200 node /opt/gjc/bin/gjc.js notify daemon-internal', // node runtime - ' 300 grep gjc server.log', // bare word — NOT evidence - ' 400 vim gjc-notes.md', // 유사 이름 — NOT evidence - ' 500 zsh -c export PATH=…; /Users/dev/.bun/bin/gjc; rc=$?', // launcher wrapper + ' 100 gjc --no-session', + ' 101 /Users/x/.bun/bin/gjc --flag', + ' 102 bun /Users/x/.bun/install/global/node_modules/@gajae-code/coding-agent/bin/gjc.js', + ' 103 node /opt/gjc.js', + ' 104 bun /Volumes/Data/Dev Workspace/tools/gjc.js', + ' 105 bun /Users/dev/.bun/bin/gjc', + ' 200 vim /tmp/gjc', + ' 201 cat /opt/gjc.js', + ' 202 bash -c "echo /usr/bin/gjc"', + ' 203 node build/gjc-tools.js', ].join('\n')); - assert.deepEqual([...pids].sort((a, b) => a - b), [89726, 100, 200, 500].sort((a, b) => a - b)); + + assert.deepEqual([...pids].sort((a, b) => a - b), [100, 101, 102, 103, 104, 105]); }); test('classifyExternalSessions: bun으로 도는 gjc도 gjcPids로 제외 (macOS live lane contract)', () => { diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index dff593bf12..3dc9d4dd90 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { buildPidChain, computeLiveSessions, + expandProcessDescendants, extractSessionPathsFromLsof, findIdleGjcTmuxSessions, IDLE_GJC_ID_PREFIX, @@ -11,6 +12,7 @@ import { parseLastModelChange, parseLsofPidSessions, parsePidParents, + parsePsProcessRecords, parseTmuxPanes, tmuxHasPanes, } from '@/modules/providers/services/live-sessions.service.js'; @@ -49,6 +51,31 @@ test('parsePidParents parses headerless `ps -eo pid=,ppid=` output (BSD right-al const parents = parsePidParents(' 1 0\n89726 89725\n93770 93769\n\nnot a row\n'); assert.deepEqual([...parents], [[1, 0], [89726, 89725], [93770, 93769]]); }); +test('parsePsProcessRecords preserves args with spaces from one ps snapshot', () => { + const records = parsePsProcessRecords([ + ' 100 1 bun /Volumes/Data/Dev Workspace/tools/gjc.js --flag', + ' 101 100 node /opt/worker.js', + 'malformed row', + ].join('\n')); + + assert.deepEqual(records, [ + { pid: 100, ppid: 1, args: 'bun /Volumes/Data/Dev Workspace/tools/gjc.js --flag' }, + { pid: 101, ppid: 100, args: 'node /opt/worker.js' }, + ]); +}); + +test('expandProcessDescendants includes every child level and stops at ppid cycles', () => { + const descendants = expandProcessDescendants(new Set([10, 20]), [ + { pid: 10, ppid: 1 }, + { pid: 11, ppid: 10 }, + { pid: 12, ppid: 11 }, + { pid: 13, ppid: 10 }, + { pid: 20, ppid: 21 }, + { pid: 21, ppid: 20 }, + ]); + + assert.deepEqual([...descendants], [10, 20, 11, 13, 21, 12]); +}); test('buildPidChain walks [pid, ppid, …] toward init from a ps snapshot', () => { // 실측 macOS shape: bun(gjc) → zsh -c wrapper → tmux pane pid. From 60f99f779019cdb08c26e42bc7e374ac51c328cc Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:55:35 +0900 Subject: [PATCH 17/26] =?UTF-8?q?fix(app):=20=EC=84=B8=EB=8C=80=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=EB=A5=BC=20=EC=8B=A4=EC=84=B8=EC=85=98=20row?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8F=84=20=EA=B0=90=EC=A7=80=20+=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=ED=9B=84=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=EC=A4=91=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 MEDIUM 2건: 새 세대가 synthetic 행 없이 transcript 행으로 바로 나타나면 waiting view가 옛 세대에 영구 정체했다 — 같은 이름의 모든 lineage 행에서 다른 non-null 세대 관측 시 무효화. 15초 타임아웃 뒤에도 초당 프로젝트 재조회가 계속됐다 — 재조회만 중단, 복구 자동 이동은 유지(의도적 결정, 주석 명기). --- src/components/app/AppContent.tsx | 6 ++- src/components/app/idleTransition.test.ts | 49 +++++++++++++++++++++-- src/components/app/idleTransition.ts | 13 ++++-- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 7756481b8e..a5b0de8b82 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -236,7 +236,10 @@ function AppContentInner() { case 'resolving': setIdleAmbiguous(false); setResolving((current) => nextResolvingOnStep(current, step)); - void refreshProjectsSilently(); + // timeout bounds background refresh work, not recovery navigation. + if (!resolvingTimedOut) { + void refreshProjectsSilently(); + } return; case 'navigate': reset(); @@ -249,6 +252,7 @@ function AppContentInner() { projects, refreshProjectsSilently, reset, + resolvingTimedOut, sidebarSharedProps.liveSessionLineage, sidebarSharedProps.liveSessionNames, sidebarSharedProps.liveSessionTmuxIds, diff --git a/src/components/app/idleTransition.test.ts b/src/components/app/idleTransition.test.ts index ebd6ac6a62..c1fa351e69 100644 --- a/src/components/app/idleTransition.test.ts +++ b/src/components/app/idleTransition.test.ts @@ -92,15 +92,58 @@ test('newEligibleSessionIds excludes candidates present when the waiting view op }); test('isGenerationReplaced treats source removal as a normal transition', () => { - assert.equal(isGenerationReplaced(target(), new Map()), false); + assert.equal(isGenerationReplaced(target(), new Map(), new Set(), new Map()), false); }); test('isGenerationReplaced accepts the current source generation', () => { - assert.equal(isGenerationReplaced(target(), new Map([[idleId, tmuxId]])), false); + const names = new Map([[idleId, tmuxName]]); + const lineage = new Set([idleId]); + const tmuxIds = new Map([[idleId, tmuxId]]); + + assert.equal(isGenerationReplaced(target(), names, lineage, tmuxIds), false); }); test('isGenerationReplaced detects an observed source generation replacement', () => { - assert.equal(isGenerationReplaced(target(), new Map([[idleId, '$2']])), true); + const names = new Map([[idleId, tmuxName]]); + const lineage = new Set([idleId]); + const tmuxIds = new Map([[idleId, '$2']]); + + assert.equal(isGenerationReplaced(target(), names, lineage, tmuxIds), true); +}); + +test('computeIdleStep invalidates when only a real replacement generation remains', () => { + const names = new Map([['real-$2', tmuxName]]); + const lineage = new Set(['real-$2']); + const tmuxIds = new Map([['real-$2', '$2']]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { type: 'invalidate' }); +}); + +test('computeIdleStep invalidates when a stale synthetic row and real replacement coexist', () => { + const names = new Map([[idleId, tmuxName], ['real-$2', tmuxName]]); + const lineage = new Set([idleId, 'real-$2']); + const tmuxIds = new Map([[idleId, tmuxId], ['real-$2', '$2']]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { type: 'invalidate' }); +}); + +test('computeIdleStep keeps resolving for a real same-generation row', () => { + const names = new Map([['real-$1', tmuxName]]); + const lineage = new Set(['real-$1']); + const tmuxIds = new Map([['real-$1', tmuxId]]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { + type: 'resolving', + targetId: 'real-$1', + }); +}); + +test('computeIdleStep ignores a non-lineage same-name replacement row', () => { + const names = new Map([['unrelated-$2', tmuxName]]); + const lineage = new Set(); + const tmuxIds = new Map([['unrelated-$2', '$2']]); + + assert.deepEqual(computeIdleStep(target(), names, lineage, tmuxIds, () => false), { type: 'idle' }); }); test('computeIdleStep stays idle when stale debounce candidates were captured at open time', () => { diff --git a/src/components/app/idleTransition.ts b/src/components/app/idleTransition.ts index 9406b9bfc7..aee7c4c15c 100644 --- a/src/components/app/idleTransition.ts +++ b/src/components/app/idleTransition.ts @@ -34,11 +34,16 @@ export function newEligibleSessionIds(target: IdleGjcTarget, .filter((id) => !excluded.has(id)); } -/** 무효화는 **관측된 세대 교체**에만. 단순 소멸(undefined)은 정상 전환 수반이므로 false. [P1-A] */ +/** 무효화는 동명 live row의 관측된 세대 교체에만. 단순 소멸(undefined)은 정상 전환 수반이므로 false. [P1-A] */ export function isGenerationReplaced(target: IdleGjcTarget, + names: ReadonlyMap, lineage: ReadonlySet, tmuxIds: ReadonlyMap): boolean { - const src = tmuxIds.get(`idle-gjc:${target.tmuxName}`); - return src !== undefined && src !== target.tmuxId; + for (const [id, name] of names) { + if (name !== target.tmuxName || !lineage.has(id)) continue; + const tmuxId = tmuxIds.get(id); + if (typeof tmuxId === 'string' && tmuxId !== target.tmuxId) return true; + } + return false; } export type IdleStep = @@ -51,7 +56,7 @@ export type IdleStep = export function computeIdleStep(target: IdleGjcTarget, names: ReadonlyMap, lineage: ReadonlySet, tmuxIds: ReadonlyMap, ownerLoaded: (id: string) => boolean): IdleStep { - if (isGenerationReplaced(target, tmuxIds)) return { type: 'invalidate' }; + if (isGenerationReplaced(target, names, lineage, tmuxIds)) return { type: 'invalidate' }; const cands = newEligibleSessionIds(target, names, lineage, tmuxIds); if (cands.length === 0) return { type: 'idle' }; if (cands.length > 1) return { type: 'ambiguous' }; // 다중 transcript 정상 구성 → 자동전환 거부[P1-C] From 98509090da3c8ceeaad7113d62e7c1e92cb56218 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:55:35 +0900 Subject: [PATCH 18/26] =?UTF-8?q?fix(spawn):=20=EC=9E=91=EC=97=85=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EC=A0=9C=EC=95=88=EC=9D=84=20=EC=A0=88=EB=8C=80?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EB=B0=98=ED=99=98=20=E2=80=94=20?= =?UTF-8?q?=ED=99=88=20=EB=8F=99=EB=AA=85=20=ED=8F=B4=EB=8D=94=EC=99=80?= =?UTF-8?q?=EC=9D=98=20=EB=AA=A8=ED=98=B8=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 MEDIUM: bare 이름 제안은 홈에 같은 이름이 있으면 표시(작업공간 우선)와 실제 스폰 위치(홈 우선)가 어긋났다. 작업공간 항목은 절대경로(관제탑이 그대로 수용), 홈 항목은 상대 유지 — 고른 문자열이 곧 스폰 위치다. 절대경로 prefix 연속 타이핑 지원(+traversal 가드 테스트). LOW: 입력 클리어/실패 후 잔상 제안 무효화(seq 증가·목록 클리어). --- .../providers/services/home-dirs.service.ts | 36 +++++++++++++------ .../providers/tests/home-dirs.service.test.ts | 27 ++++++++------ src/shared/view/HomeDirInput.tsx | 16 +++++++-- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/server/modules/providers/services/home-dirs.service.ts b/server/modules/providers/services/home-dirs.service.ts index 63e3888db8..33a899db0f 100644 --- a/server/modules/providers/services/home-dirs.service.ts +++ b/server/modules/providers/services/home-dirs.service.ts @@ -129,26 +129,42 @@ export function parseExtraSpawnRoots(raw: string | undefined): string[] { /** * Spawn-scope suggestions: extra spawn roots (the tower's TOWER_ALLOWED_ROOTS, - * e.g. the /Volumes workspace) come FIRST, then $HOME — the workspace is where - * new sessions usually live. Suggestions stay bare relative names on purpose: - * the tower's resolveSpawnCwd resolves them $HOME-first then per allowed root, - * so the picked string is exactly what /spawn accepts. On a rare name collision - * (same child under $HOME and a root) the tower's $HOME-first rule wins. + * e.g. the /Volumes workspace) come FIRST, then $HOME. Extra-root entries are + * returned as ABSOLUTE paths (the tower's resolveSpawnCwd takes absolute paths + * as-is), home entries stay home-relative — so a picked suggestion is never + * ambiguous when the same child name exists under both $HOME and a root + * (리뷰 반영: bare-name collisions used to display workspace-first but spawn + * $HOME-first). Absolute prefixes are accepted here (continuing to type after + * picking a workspace entry) and are contained to the extra roots. */ export async function getSpawnDirSuggestions( prefix: string, homeDir: string = os.homedir(), extraRoots: string[] = parseExtraSpawnRoots(process.env.TOWER_ALLOWED_ROOTS), ): Promise { - if (prefix.includes('\0') || prefix.startsWith('/') || prefix.length > 512) { + if (prefix.includes('\0') || prefix.length > 512) { return []; } const lanes: string[][] = []; - for (const root of extraRoots) { - const rootReal = await safeRealpath(root); - lanes.push(rootReal ? await suggestUnderBase(prefix, rootReal, [rootReal]) : []); + if (prefix.startsWith('/')) { + // Absolute prefix: only extra roots may serve it, and only from inside. + for (const root of extraRoots) { + const rootReal = await safeRealpath(root); + if (!rootReal || (prefix !== rootReal && !prefix.startsWith(`${rootReal}${path.sep}`))) { + continue; + } + const rel = prefix === rootReal ? '' : prefix.slice(rootReal.length + 1); + const entries = await suggestUnderBase(rel, rootReal, [rootReal]); + lanes.push(entries.map((entry) => `${rootReal}${path.sep}${entry}`)); + } + } else { + for (const root of extraRoots) { + const rootReal = await safeRealpath(root); + const entries = rootReal ? await suggestUnderBase(prefix, rootReal, [rootReal]) : []; + lanes.push(entries.map((entry) => `${rootReal}${path.sep}${entry}`)); + } + lanes.push(await suggestUnderBase(prefix, homeDir, await resolveAllowedRoots(homeDir))); } - lanes.push(await suggestUnderBase(prefix, homeDir, await resolveAllowedRoots(homeDir))); const seen = new Set(); const merged: string[] = []; for (const lane of lanes) { diff --git a/server/modules/providers/tests/home-dirs.service.test.ts b/server/modules/providers/tests/home-dirs.service.test.ts index c0a3971978..1866d6d27b 100644 --- a/server/modules/providers/tests/home-dirs.service.test.ts +++ b/server/modules/providers/tests/home-dirs.service.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -69,9 +69,9 @@ test('parseExtraSpawnRoots keeps only absolute csv entries', () => { assert.deepEqual(parseExtraSpawnRoots(' /Volumes/Data/Dev Workspace , relative/path , '), ['/Volumes/Data/Dev Workspace']); }); -test('getSpawnDirSuggestions: extra roots first, home after, deduped; empty prefix lists defaults', async () => { +test('getSpawnDirSuggestions: extra roots absolute-first, home relative after; collisions stay distinguishable', async () => { const home = await mkdtemp(path.join(os.tmpdir(), 'spawn-home-')); - const root = await mkdtemp(path.join(os.tmpdir(), 'spawn root-')); // space: 실제 워크스페이스 경로 형태 + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), 'spawn root-'))); // space: 실제 워크스페이스 경로 형태 try { await mkdir(path.join(home, 'zeta')); await mkdir(path.join(home, 'shared')); @@ -79,18 +79,25 @@ test('getSpawnDirSuggestions: extra roots first, home after, deduped; empty pref await mkdir(path.join(root, 'shared')); await mkdir(path.join(root, 'aegis-alpha', 'sub')); - // Empty prefix = default list: workspace children first, then home's. + // Empty prefix = default list: workspace children (absolute) first, then + // home's (relative). The 'shared' collision yields two DISTINCT strings — + // the picked suggestion always spawns exactly where it says. assert.deepEqual( await getSpawnDirSuggestions('', home, [root]), - ['aegis-alpha', 'shared', 'zeta'], + [path.join(root, 'aegis-alpha'), path.join(root, 'shared'), 'shared', 'zeta'], ); // Fragment matching hits the workspace root even when home has no match. - assert.deepEqual(await getSpawnDirSuggestions('aeg', home, [root]), ['aegis-alpha']); - // Nested listing under a workspace child works. - assert.deepEqual(await getSpawnDirSuggestions('aegis-alpha/', home, [root]), ['aegis-alpha/sub']); - // Traversal/absolute prefixes stay rejected in spawn scope too. - assert.deepEqual(await getSpawnDirSuggestions('../x', home, [root]), []); + assert.deepEqual(await getSpawnDirSuggestions('aeg', home, [root]), [path.join(root, 'aegis-alpha')]); + // Absolute prefix (continuing after a pick) lists inside the root only. + assert.deepEqual( + await getSpawnDirSuggestions(`${path.join(root, 'aegis-alpha')}/`, home, [root]), + [path.join(root, 'aegis-alpha', 'sub')], + ); + // Absolute prefixes outside every allowed root return nothing. assert.deepEqual(await getSpawnDirSuggestions('/etc/', home, [root]), []); + // Traversal is rejected in both forms (lexical guard inside the base). + assert.deepEqual(await getSpawnDirSuggestions('../x', home, [root]), []); + assert.deepEqual(await getSpawnDirSuggestions(`${root}/../etc/`, home, [root]), []); // A missing extra root degrades to home-only. assert.deepEqual(await getSpawnDirSuggestions('sh', home, [path.join(root, 'nope')]), ['shared']); } finally { diff --git a/src/shared/view/HomeDirInput.tsx b/src/shared/view/HomeDirInput.tsx index 6688ff6717..bf51c781e1 100644 --- a/src/shared/view/HomeDirInput.tsx +++ b/src/shared/view/HomeDirInput.tsx @@ -34,6 +34,9 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c // "pick a project" default list (workspace roots first). The home scope // keeps its old behavior — no dropdown until something is typed. if (!value.trim() && scope !== 'spawn') { + // Invalidate any in-flight request too — a slow response must not + // repopulate suggestions after the input was cleared (리뷰 반영). + requestSeqRef.current += 1; setSuggestions([]); return undefined; } @@ -41,7 +44,13 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c debounceRef.current = setTimeout(async () => { try { const response = await api.dirSuggestions(value.trim(), scope === 'spawn' ? 'spawn' : null); - if (!response.ok) return; + if (seq !== requestSeqRef.current) { + return; + } + if (!response.ok) { + setSuggestions([]); + return; + } const body = await response.json(); const list: string[] = body?.data?.suggestions ?? []; if (seq === requestSeqRef.current) { @@ -49,7 +58,10 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c setSuggestions(list.filter((entry) => entry !== value.trim())); } } catch { - // best-effort + // Best-effort, but never leave stale entries behind a failed fetch. + if (seq === requestSeqRef.current) { + setSuggestions([]); + } } }, DEBOUNCE_MS); return () => { From 4f3c4e151f890b4ff949de36b3ca019c75ea5cbe Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:55:35 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refactor(client):=20tmux=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EA=B7=9C=EC=B9=99=20=EC=83=81=EC=88=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=ED=99=94=20+=20=EC=A3=BD=EC=9D=80=20status=20variant?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인라인 정규식 3곳(스폰 규칙 1, 표시 안전성 2)을 shared/tmuxSessionName.ts로 통합 — 두 등급(생성용 strict / 표시용 loose)의 차이를 문서화. SpawnStatus의 도달 불가 'ok' variant 제거. --- src/components/main-content/view/MainContent.tsx | 5 +++-- .../view/subcomponents/SidebarSpawnSession.tsx | 11 +++++------ src/shared/tmuxSessionName.ts | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 src/shared/tmuxSessionName.ts diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 2eba9dce37..7f38b6f4df 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -3,6 +3,7 @@ import { Menu, SquareTerminal, X } from 'lucide-react'; import ChatInterface from '../../chat/view/ChatInterface'; import LiveRelayComposer from '../../chat/view/subcomponents/LiveRelayComposer'; +import { isSafeDisplayTmuxName } from '../../../shared/tmuxSessionName'; import { composerKey } from '../../app/idleTransition'; import PluginTabContent from '../../plugins/view/PluginTabContent'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; @@ -175,7 +176,7 @@ function MainContent({ // same footprint as a gjc session. Rendered before the no-project empty state // because the target carries its own project (PTY cwd only). if (externalTerminal) { - const safeName = /^[A-Za-z0-9._-]{1,64}$/.test(externalTerminal.tmuxName) ? externalTerminal.tmuxName : null; + const safeName = isSafeDisplayTmuxName(externalTerminal.tmuxName) ? externalTerminal.tmuxName : null; return (

@@ -227,7 +228,7 @@ function MainContent({ ); } if (idleTarget) { - const safeName = /^[A-Za-z0-9._-]{1,64}$/.test(idleTarget.tmuxName) ? idleTarget.tmuxName : null; + const safeName = isSafeDisplayTmuxName(idleTarget.tmuxName) ? idleTarget.tmuxName : null; return (
diff --git a/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx b/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx index 1df91581ac..b714154a71 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSpawnSession.tsx @@ -3,15 +3,14 @@ import { Plus } from 'lucide-react'; import { api } from '../../../../utils/api'; import HomeDirInput from '../../../../shared/view/HomeDirInput'; +import { SPAWN_NAME_RE } from '../../../../shared/tmuxSessionName'; type SpawnStatus = | { kind: 'idle' } | { kind: 'spawning' } - | { kind: 'ok'; text: string } | { kind: 'error'; text: string }; -// Mirrors the tower's NAME_RE — validating here turns the tower's English 400 -// ("invalid session name") into an actionable Korean message before any request. -const SPAWN_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +// The tower's English 400 ("invalid session name") becomes this actionable +// Korean message, checked client-side before any request (rule: SPAWN_NAME_RE). const NAME_RULE_TEXT = '이름은 영문·숫자로 시작, 영문·숫자·. _ - 만 (공백·/ 불가)'; // A new gjc session is created through the control tower's /spawn (proxied by the @@ -100,8 +99,8 @@ export default function SidebarSpawnSession() { placeholder="작업 폴더 (예: aegis-alpha, workspace/my-proj)" scope="spawn" /> - {status.kind !== 'idle' && status.kind !== 'spawning' && ( -

+ {status.kind === 'error' && ( +

{status.text}

)} diff --git a/src/shared/tmuxSessionName.ts b/src/shared/tmuxSessionName.ts new file mode 100644 index 0000000000..448d0a32a1 --- /dev/null +++ b/src/shared/tmuxSessionName.ts @@ -0,0 +1,16 @@ +/** + * tmux session-name rules, single client-side source of truth. + * + * Two deliberately different grades: + * - SPAWN_NAME_RE mirrors the tower's NAME_RE for CREATING sessions (must + * start alphanumeric — matches the server/tower validation exactly). + * - SAFE_DISPLAY_NAME_RE is the looser guard for RENDERING/attaching names of + * sessions we did not create (external tmux sessions may legally start with + * `.`/`_`/`-`); it only excludes shell-hostile characters. + */ +export const SPAWN_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +export const SAFE_DISPLAY_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/; + +export function isSafeDisplayTmuxName(name: string): boolean { + return SAFE_DISPLAY_NAME_RE.test(name); +} From fd977cf25fdb305947eae54c9302364fdc2416d2 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:58:09 +0900 Subject: [PATCH 20/26] =?UTF-8?q?fix(server):=20ps=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EB=B0=B0=EC=84=A0=20=ED=9A=8C=EA=B7=80=20=E2=80=94?= =?UTF-8?q?=203=EC=97=B4=20=EC=B6=9C=EB=A0=A5=EC=9D=84=202=EC=97=B4=20?= =?UTF-8?q?=ED=8C=8C=EC=84=9C=EC=97=90=20=EC=A3=BC=EC=9E=85=ED=95=B4=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EC=A0=84=EB=A9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lsof pid 한정 커밋의 배선 버그: 'ps -eo pid=,ppid=,args=' 원문을 2열용 parseGjcPidsFromPsArgs에 넣어 ppid가 argv[0]으로 해석 → gjc 증거 0건 → live/idle 행 전멸(배포 후 실측). 레코드 기반 gjcPidsFromProcessRecords로 교체하고, 정확히 이 배선을 고정하는 회귀 테스트 추가. --- .../services/external-cli-sessions.service.ts | 19 +++++++++++++++++++ .../services/live-sessions.service.ts | 4 ++-- .../tests/live-sessions.service.test.ts | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index 4b464fe3fe..0dd76068bc 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -117,6 +117,7 @@ function hasGjcArgvEvidence(commandLine: string): boolean { return firstArgument.startsWith('/') && /^\/.*\/gjc(?:\.js)?(?=\s|$)/.test(firstArgument); } +/** Pids with gjc argv evidence from `ps -eo pid=,args=` (TWO-column) output. */ export function parseGjcPidsFromPsArgs(output: string): Set { const pids = new Set(); for (const raw of output.split(/\r?\n/)) { @@ -128,6 +129,24 @@ export function parseGjcPidsFromPsArgs(output: string): Set { return pids; } +/** + * Same evidence over already-parsed process records (pid + full command line). + * Use this with the shared `ps -eo pid=,ppid=,args=` snapshot — feeding that + * THREE-column raw output into `parseGjcPidsFromPsArgs` silently treats the + * ppid as argv[0] and finds nothing (실사고: 감지 전멸 회귀). + */ +export function gjcPidsFromProcessRecords( + records: ReadonlyArray<{ pid: number; args: string }>, +): Set { + const pids = new Set(); + for (const record of records) { + if (hasGjcArgvEvidence(record.args.trim())) { + pids.add(record.pid); + } + } + return pids; +} + /** * Pure classification: tmux panes + a ps snapshot → external CLI sessions. * diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index 21fe697511..df0ccd83ca 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import { open, realpath, stat } from 'node:fs/promises'; -import { parseGjcPidsFromPsArgs } from './external-cli-sessions.service.js'; +import { gjcPidsFromProcessRecords } from './external-cli-sessions.service.js'; /** * Live gjc session detection + tmux-session naming. @@ -578,7 +578,7 @@ async function scanLiveGjcSessions(): Promise { try { const psOutput = await runCommand('ps', ['-eo', 'pid=,ppid=,args=']); psRecords = parsePsProcessRecords(psOutput); - gjcPids = parseGjcPidsFromPsArgs(psOutput); + gjcPids = gjcPidsFromProcessRecords(psRecords); } catch { // fall through with no process evidence } diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index 3dc9d4dd90..b1edbc3c0d 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -16,6 +16,7 @@ import { parseTmuxPanes, tmuxHasPanes, } from '@/modules/providers/services/live-sessions.service.js'; +import { gjcPidsFromProcessRecords } from '@/modules/providers/services/external-cli-sessions.service.js'; test('tmuxHasPanes detects a running tmux server (>=1 pane line)', () => { assert.equal(tmuxHasPanes('omg\t111\t/home/u/workspace/oh-my-gjc\n'), true); @@ -371,3 +372,18 @@ test('findIdleGjcTmuxSessions: bun-wrapped gjc pane은 gjcPids 증거로 idle }); assert.deepEqual(result, [{ name: 'test', sid: '$7' }]); }); + +test('shared ps snapshot wiring: 3-column records feed gjc evidence correctly (감지 전멸 회귀 방지)', () => { + // Shape of the ONE `ps -eo pid=,ppid=,args=` snapshot the scan uses. Feeding + // this raw output into the 2-column parser made argv[0] the ppid digits and + // silently zeroed all detection — the exact regression this test pins. + const snapshot = [ + ' 89726 89725 bun /Users/dev/.bun/bin/gjc', + ' 12001 1 vim /tmp/gjc', + ' 12002 1 node /opt/other/tool.js', + ' 12003 89726 bun /Volumes/Data/Dev Workspace/tools/gjc.js', + ].join('\n'); + const records = parsePsProcessRecords(snapshot); + const pids = gjcPidsFromProcessRecords(records); + assert.deepEqual([...pids].sort((a, b) => a - b), [89726, 12003].sort((a, b) => a - b)); +}); From 54b143b79aad71eff52eae51bcf7b1803521d45f Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:12:11 +0900 Subject: [PATCH 21/26] =?UTF-8?q?feat(relay):=20tmux=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=A8=EB=B6=80=20=E2=80=94?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8A=A4=ED=86=A0=EC=96=B4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=A6=B4=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 터미널 릴레이는 텍스트만 나를 수 있으므로, 이미지는 기존 자산 업로드 (POST /api/assets/images, 네이티브 컴포저와 동일 스토어)로 저장하고 절대경로를 메시지에 동봉한다 — pane의 gjc가 멀티모달 read 도구로 연다. 버튼+클립보드 붙여넣기, 최대 5장(엔드포인트 한도 미러), 업로드 실패 시 전송 자체를 취소. --- .../view/subcomponents/LiveRelayComposer.tsx | 103 +++++++++++++++++- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx index a95dabf7b6..f397fc707b 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import { ImagePlus, X } from 'lucide-react'; -import { api } from '../../../../utils/api'; +import { api, authenticatedFetch } from '../../../../utils/api'; import { requestLivePollBoost } from '../../../../utils/livePollBoost'; type RelayStatus = @@ -10,23 +11,54 @@ type RelayStatus = | { kind: 'queued'; text: string } | { kind: 'error'; text: string }; +// Mirrors the assets endpoint's `upload.array('images', 5)` limit. +const MAX_ATTACHED_IMAGES = 5; + /** * Composer for a live (read-only) session. It does NOT inject into the * conversation — it relays the message to the control tower's /send (via the * server proxy), which owns outbox/queueing + injection + verification. Shows * delivered / queued / error feedback based on the tower's response. * + * Image attachments ride the text-only relay as FILE PATHS: uploads go to the + * global assets store (POST /api/assets/images — same as the native chat + * composer), and the relayed message references the stored absolute paths. + * The gjc in the pane opens them with its multimodal read tool; the tower + * cannot carry binary data into a terminal, so this is the whole mechanism. + * * The status line leads with the session's CURRENT MODEL (from the gjc * transcript's last model_change, threaded through the live poll) — the tmux * name stays as a muted suffix so the send target remains identifiable. */ export default function LiveRelayComposer({ tmuxName, tmuxId = null, model = null }: { tmuxName: string; tmuxId?: string | null; model?: string | null }) { const [input, setInput] = useState(''); + const [attached, setAttached] = useState([]); const [status, setStatus] = useState({ kind: 'idle' }); + const fileInputRef = useRef(null); + + const addFiles = (files: Iterable) => { + const images = [...files].filter((file) => file.type.startsWith('image/')); + if (images.length === 0) { + return; + } + setAttached((prev) => [...prev, ...images].slice(0, MAX_ATTACHED_IMAGES)); + }; + + const uploadAttachments = async (): Promise => { + const formData = new FormData(); + attached.forEach((file) => formData.append('images', file)); + const response = await authenticatedFetch('/api/assets/images', { method: 'POST', headers: {}, body: formData }); + if (!response.ok) { + return null; + } + const result = await response.json().catch(() => null) as { images?: Array<{ path?: string }> } | null; + const paths = (result?.images ?? []).map((image) => image?.path).filter((p): p is string => typeof p === 'string'); + return paths.length === attached.length ? paths : null; + }; const send = async () => { - const message = input.trim(); - if (!message || status.kind === 'sending') { + const text = input.trim(); + if ((!text && attached.length === 0) || status.kind === 'sending') { return; } // Server contract: the $N generation token is required (fail-closed). No @@ -37,6 +69,16 @@ export default function LiveRelayComposer({ tmuxName, tmuxId = null, model = nul } setStatus({ kind: 'sending' }); try { + let message = text; + if (attached.length > 0) { + const paths = await uploadAttachments(); + if (!paths) { + setStatus({ kind: 'error', text: '이미지 업로드 실패 — 전송 취소됨' }); + return; + } + const block = `[첨부 이미지 ${paths.length}장 — read 도구로 열어 확인:\n${paths.map((p) => `- ${p}`).join('\n')}]`; + message = text ? `${text}\n\n${block}` : block; + } const response = await api.liveSessionSend(tmuxName, message, tmuxId); const body = await response.json().catch(() => null); const data = (body?.data ?? body ?? {}) as { ok?: boolean; reachable?: boolean; queued?: boolean; detail?: string }; @@ -51,6 +93,7 @@ export default function LiveRelayComposer({ tmuxName, tmuxId = null, model = nul return; } setInput(''); + setAttached([]); setStatus(data.queued ? { kind: 'queued', text: '대기열 적재됨' } : { kind: 'ok', text: '전달됨' }); // The user is now watching for the pane's reaction (idle→live transition, // new transcript activity) — poll fast for a short window. @@ -77,10 +120,60 @@ export default function LiveRelayComposer({ tmuxName, tmuxId = null, model = nul · {status.text} )}
+ {attached.length > 0 && ( +
+ {attached.map((file, index) => ( + + {file.name} + + + ))} +
+ )}
+ { + if (event.target.files) { + addFiles(event.target.files); + } + event.target.value = ''; + }} + /> +