diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 27375bcdc6..57b9a31804 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -9,8 +9,8 @@ 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 { isValidTmuxName, sendToLiveSession, isValidSpawnName, spawnLiveSession, killLiveSession } from '@/modules/providers/services/live-send.service.js'; +import { getHomeDir, getHomeDirSuggestions, getSpawnDirSuggestions } from '@/modules/providers/services/home-dirs.service.js'; +import { isValidTmuxName, sendToLiveSession, isValidSpawnName, spawnLiveSession, killLiveSession, answerLiveSession } from '@/modules/providers/services/live-send.service.js'; import type { LLMProvider, McpScope, @@ -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 })); }), ); @@ -624,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) { @@ -633,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, @@ -641,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( @@ -702,6 +708,27 @@ router.post( }), ); +router.post( + '/sessions/live/answer', + asyncHandler(async (req: Request, res: Response) => { + // Answer a live session's ask-TUI menu: the tower navigates to the exact + // option label and commits only after verifying the cursor row. Same + // lineage + generation-token gate as send/kill — a stale UI must not drive + // keystrokes into a same-named session that replaced the one it saw. + const body = (req.body ?? {}) as { tmuxName?: unknown; tmuxId?: unknown; label?: unknown }; + if (!isValidTmuxName(body.tmuxName)) { + throw new AppError('A valid tmuxName is required.', { code: 'INVALID_TMUX_NAME', statusCode: 400 }); + } + const label = typeof body.label === 'string' ? body.label : ''; + if (!label.trim()) { + throw new AppError('label is required.', { code: 'EMPTY_LABEL', statusCode: 400 }); + } + await assertLineageTmuxTarget(body.tmuxName, readTmuxIdParam(body.tmuxId)); + const result = await answerLiveSession(body.tmuxName, label); + res.json(createApiSuccessResponse(result)); + }), +); + router.delete( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index 6cd34f0b2e..0dd76068bc 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,11 +69,84 @@ 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; } +/** + * 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`. 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); +} + +/** 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/)) { + const match = /^\s*(\d+)\s+(.+)$/.exec(raw); + if (match && hasGjcArgvEvidence(match[2].trim())) { + pids.add(Number.parseInt(match[1], 10)); + } + } + 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. * @@ -79,6 +162,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) { @@ -89,6 +174,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); @@ -108,6 +200,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); } @@ -156,7 +251,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(() => { @@ -183,14 +284,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/home-dirs.service.ts b/server/modules/providers/services/home-dirs.service.ts index bcee91bc30..c3457a221a 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,94 @@ 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. + * + * Extra-root entries are UNIFORMLY "/rest" ("Dev Workspace/ + * argus"): one consistent, short, root-identifying shape (실사용 피드백: + * full absolute paths drowned the dropdown; mixing bare and prefixed forms + * was confusing; bare names alone spawned in $HOME on a name collision — + * 리뷰 반영). The tower resolves the alias against the root's PARENT, same + * containment contract. Home entries stay home-relative. Alias and absolute + * prefixes are both accepted for continued typing, contained to the 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.length > 512) { + return []; + } + const lanes: string[][] = []; + 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); + if (!rootReal) { + lanes.push([]); + continue; + } + const alias = path.basename(rootReal); + if (prefix === alias || prefix.startsWith(`${alias}/`)) { + const rel = prefix === alias ? '' : prefix.slice(alias.length + 1); + const entries = await suggestUnderBase(rel, rootReal, [rootReal]); + lanes.push(entries.map((entry) => `${alias}/${entry}`)); + continue; + } + const lane: string[] = []; + // Typing the alias itself completes to the root ("Dev W…" → pick → + // "Dev Workspace/" lists its children). + if (prefix.length > 0 && alias.startsWith(prefix)) { + lane.push(alias); + } + const entries = await suggestUnderBase(prefix, rootReal, [rootReal]); + lane.push(...entries.map((entry) => `${alias}/${entry}`)); + lanes.push(lane); + } + 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/services/live-send.service.ts b/server/modules/providers/services/live-send.service.ts index 36643e84ea..21442b7605 100644 --- a/server/modules/providers/services/live-send.service.ts +++ b/server/modules/providers/services/live-send.service.ts @@ -122,3 +122,35 @@ export async function killLiveSession(tmuxName: string): Promise const text = await response.text().catch(() => ''); return classifyKillResponse(response.status, text); } + +// ─── Answer a live session's ask-TUI menu (control tower /answer) ──────────── +// The tower navigates the on-screen menu to the exact option LABEL and commits +// only after verifying the cursor row — never blind keystroke counting. 409 = +// the menu is gone / label mismatch / multi-select (answer at the terminal). + +export type LiveAnswerResult = { ok: boolean; reachable: boolean; stale: boolean; detail: string }; + +/** Pure classifier for the tower's /answer response (409 = menu gone / mismatch). */ +export function classifyAnswerResponse(status: number, body: string): LiveAnswerResult { + const detail = body.trim().slice(0, 500); + const ok = status >= 200 && status < 300; + return { ok, reachable: true, stale: status === 409, detail }; +} + +/** Proxies an answer (option pick) to the tower's /answer. Never throws. */ +export async function answerLiveSession(tmuxName: string, label: string): Promise { + const body = new URLSearchParams({ session: tmuxName, label }); + let response: Response; + try { + response = await fetch(`${towerUrl()}/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(10000), + }); + } catch { + return { ok: false, reachable: false, stale: false, detail: 'control tower is not reachable' }; + } + const text = await response.text().catch(() => ''); + return classifyAnswerResponse(response.status, text); +} diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index 9d5ec99e90..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, readFile, realpath, stat } from 'node:fs/promises'; +import { open, realpath, stat } from 'node:fs/promises'; -import { parsePsTree } from './external-cli-sessions.service.js'; +import { gjcPidsFromProcessRecords } from './external-cli-sessions.service.js'; /** * Live gjc session detection + tmux-session naming. @@ -9,14 +9,17 @@ import { parsePsTree } from './external-cli-sessions.service.js'; * 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 + * - 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) * * 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). * @@ -70,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(); @@ -84,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]; @@ -93,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) ?? []) { @@ -265,7 +271,15 @@ const RUN_COMMAND_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; 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 size = 0; let settled = false; @@ -303,32 +317,83 @@ 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; +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(); + 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; } @@ -337,6 +402,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(); @@ -490,43 +570,74 @@ 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 { - lsofOutput = await runCommand('lsof', ['-c', 'gjc', '-F', 'pn']); + const psOutput = await runCommand('ps', ['-eo', 'pid=,ppid=,args=']); + psRecords = parsePsProcessRecords(psOutput); + gjcPids = gjcPidsFromProcessRecords(psRecords); } 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); + + 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 + // 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, }); } 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 = await runCommand('ps', ['-eo', 'pid,ppid,comm']); - idlePanes = findIdleGjcTmuxSessions({ - panes, - procs: parsePsTree(psOutput), - 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 86dacb0fa1..241180f0ef 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'; @@ -25,6 +26,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' }], @@ -142,3 +166,37 @@ test('classifyExternalSessions: sorted by tmux name for stable rendering', () => }); assert.deepEqual(result.map((s) => s.tmuxName), ['alpha', 'zeta']); }); + +test('parseGjcPidsFromPsArgs accepts only argv0 and the bun/node first argument', () => { + const pids = parseGjcPidsFromPsArgs([ + ' 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), [100, 101, 102, 103, 104, 105]); +}); + +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/home-dirs.service.test.ts b/server/modules/providers/tests/home-dirs.service.test.ts index ede066ba70..aad8a09d37 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'; @@ -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,57 @@ 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: workspace entries are uniformly root-alias prefixed; home stays relative', async () => { + const home = await mkdtemp(path.join(os.tmpdir(), 'spawn-home-')); + 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')); + await mkdir(path.join(root, 'aegis-alpha')); + await mkdir(path.join(root, 'shared')); + await mkdir(path.join(root, 'aegis-alpha', 'sub')); + + const alias = path.basename(root); + // Empty prefix = default list: workspace children first, ALL in the + // uniform "/" shape (one consistent form — mixing + // bare and prefixed entries confused the picker), then home's relative + // names. The 'shared' collision is naturally unambiguous this way. + assert.deepEqual( + await getSpawnDirSuggestions('', home, [root]), + [`${alias}/aegis-alpha`, `${alias}/shared`, 'shared', 'zeta'], + ); + // Fragment matching hits the workspace root even when home has no match. + assert.deepEqual(await getSpawnDirSuggestions('aeg', home, [root]), [`${alias}/aegis-alpha`]); + // Typing the alias itself offers the root as a completion step. + assert.deepEqual( + await getSpawnDirSuggestions(alias.slice(0, 3), home, [root]), + [alias], + ); + // Continued typing in the alias form lists that root's children. + assert.deepEqual( + await getSpawnDirSuggestions(`${alias}/aegis-alpha/`, home, [root]), + [`${alias}/aegis-alpha/sub`], + ); + // Absolute prefixes still work and stay contained to the roots. + 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 { + await rm(home, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/tests/live-send.service.test.ts b/server/modules/providers/tests/live-send.service.test.ts index 184df82113..5426ac468d 100644 --- a/server/modules/providers/tests/live-send.service.test.ts +++ b/server/modules/providers/tests/live-send.service.test.ts @@ -7,6 +7,7 @@ import { isValidSpawnName, classifySpawnResponse, classifyKillResponse, + classifyAnswerResponse, } from '@/modules/providers/services/live-send.service.js'; test('isValidTmuxName accepts simple session tokens, rejects unsafe ones', () => { @@ -74,3 +75,15 @@ test('classifyKillResponse: 2xx ok, 403 protected, 422 unknown (all reachable)', assert.equal(failed.protected, false); assert.equal(failed.unknown, false); }); + +test('classifyAnswerResponse: 2xx ok, 409 marks stale menu, others plain failure', () => { + assert.deepEqual(classifyAnswerResponse(200, 'answered omg: 빨강'), { + ok: true, reachable: true, stale: false, detail: 'answered omg: 빨강', + }); + assert.deepEqual(classifyAnswerResponse(409, 'ask menu is not showing "빨강"'), { + ok: false, reachable: true, stale: true, detail: 'ask menu is not showing "빨강"', + }); + assert.deepEqual(classifyAnswerResponse(422, 'no such tmux session: omg'), { + ok: false, reachable: true, stale: false, detail: 'no such tmux session: omg', + }); +}); diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index 3fb7bbf3ac..b1edbc3c0d 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -2,15 +2,21 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildPidChain, computeLiveSessions, + expandProcessDescendants, extractSessionPathsFromLsof, findIdleGjcTmuxSessions, IDLE_GJC_ID_PREFIX, + parseCwdByPidFromLsof, parseLastModelChange, parseLsofPidSessions, + parsePidParents, + parsePsProcessRecords, 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); @@ -41,6 +47,64 @@ 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('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. + 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+id by pid lineage', () => { const result = computeLiveSessions({ tmuxPresent: true, @@ -291,3 +355,35 @@ 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' }]); +}); + +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)); +}); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 678e3f8eba..a5b0de8b82 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,79 @@ 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)); + // timeout bounds background refresh work, not recovery navigation. + if (!resolvingTimedOut) { + void refreshProjectsSilently(); + } + return; + case 'navigate': + reset(); + navigate(`/session/${step.targetId}`); + return; + } + }, [ + idleTarget, + navigate, + projects, + refreshProjectsSilently, + reset, + resolvingTimedOut, + 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 +290,7 @@ function AppContentInner() { localStorage.setItem('selected-provider', message.provider); } - setExternalTerminal(null); + reset(); setActiveTab('chat'); setSidebarOpen(false); void refreshProjectsSilently(); @@ -215,7 +308,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 +408,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 new file mode 100644 index 0000000000..c1fa351e69 --- /dev/null +++ b/src/components/app/idleTransition.test.ts @@ -0,0 +1,263 @@ +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(), new Set(), new Map()), false); +}); + +test('isGenerationReplaced accepts the current source generation', () => { + 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', () => { + 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', () => { + 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..aee7c4c15c --- /dev/null +++ b/src/components/app/idleTransition.ts @@ -0,0 +1,89 @@ +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)); +} + +/** 무효화는 동명 live row의 관측된 세대 교체에만. 단순 소멸(undefined)은 정상 전환 수반이므로 false. [P1-A] */ +export function isGenerationReplaced(target: IdleGjcTarget, + names: ReadonlyMap, lineage: ReadonlySet, + tmuxIds: ReadonlyMap): boolean { + 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 = + | { 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, 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] + 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 }; +} diff --git a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx index 0f9351836f..a0c3c1bea2 100644 --- a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx +++ b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; + import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; + +import { LiveAnswerContext } from '../../liveAnswerContext'; + import { QuestionAnswerContent } from './QuestionAnswerContent'; // Regression coverage for the chat-interface crash where an AskUserQuestion @@ -75,3 +79,39 @@ test('still renders a well-formed question + answer', () => { ); assert.ok(html.includes('Pick one?')); }); + +test('live answer buttons: absent without a LiveAnswerContext, present (per unanswered option) with one', () => { + const question = { question: 'Pick one?', options: [{ label: 'A안' }, { label: 'B안' }] }; + + // No context (historical transcript) → read-only, no answer hint. + const readonly = renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { questions: [question], answers: {} }), + ); + assert.ok(!readonly.includes('세션 메뉴에 자동 반영')); + + // Live context + unanswered question → clickable option buttons appear. + const live = renderToStaticMarkup( + React.createElement( + LiveAnswerContext.Provider, + { value: async () => ({ ok: true, stale: false, detail: 'ok' }) }, + React.createElement(QuestionAnswerContent, { questions: [question], answers: {} }), + ), + ); + assert.ok(live.includes('세션 메뉴에 자동 반영')); + assert.ok(live.includes('A안')); + assert.ok(live.includes('B안')); +}); + +test('live answer buttons: suppressed once the question already has a recorded answer', () => { + const html = renderToStaticMarkup( + React.createElement( + LiveAnswerContext.Provider, + { value: async () => ({ ok: true, stale: false, detail: 'ok' }) }, + React.createElement(QuestionAnswerContent, { + questions: [{ question: 'Pick one?', options: [{ label: 'A안' }, { label: 'B안' }] }], + answers: { 'Pick one?': 'A안' }, + }), + ), + ); + assert.ok(!html.includes('세션 메뉴에 자동 반영')); +}); diff --git a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx index 005e60d5ca..18d3776e5d 100644 --- a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx +++ b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react'; + import type { Question } from '../../../types/types'; +import { useLiveAnswer } from '../../liveAnswerContext'; interface QuestionAnswerContentProps { questions: Question[]; @@ -13,7 +15,22 @@ export const QuestionAnswerContent: React.FC = ({ answers, className = '', }) => { - const [expandedIdx, setExpandedIdx] = useState(null); + // Single-question asks (the common gjc case) start expanded so options — + // and the live answer buttons — are visible without an extra click. + const [expandedIdx, setExpandedIdx] = useState( + Array.isArray(questions) && questions.length === 1 ? 0 : null, + ); + // Live ask-menu answering (present only when viewing a live tmux session). + const liveAnswer = useLiveAnswer(); + const [pick, setPick] = useState<{ label: string; status: 'sending' | 'ok' | 'stale' | 'error'; detail: string } | null>(null); + const submitPick = async (label: string) => { + if (!liveAnswer || pick?.status === 'sending') { + return; + } + setPick({ label, status: 'sending', detail: '' }); + const result = await liveAnswer(label); + setPick({ label, status: result.ok ? 'ok' : result.stale ? 'stale' : 'error', detail: result.detail }); + }; // Tool inputs are runtime data loaded from session transcripts and may be // malformed (e.g. `questions` arriving as a non-array). Guard with @@ -126,6 +143,43 @@ export const QuestionAnswerContent: React.FC = ({ {isExpanded && (
+ {/* Live answering: a pending gjc ask menu is on screen. Clicking + a label drives the tower to navigate+commit that option. The + read-only list below still shows the full set. */} + {liveAnswer && answerLabels.length === 0 && options.length > 0 && ( +
+ {options.map((opt) => { + const isPicked = pick?.label === opt.label; + const done = isPicked && pick?.status === 'ok'; + return ( + + ); + })} +

웹에서 선택 → 세션 메뉴에 자동 반영 (터미널에서 직접 골라도 됩니다)

+
+ )} {options.map((opt) => { const wasSelected = answerLabels.includes(opt.label); return ( diff --git a/src/components/chat/tools/configs/toolConfigs.ask.test.tsx b/src/components/chat/tools/configs/toolConfigs.ask.test.tsx new file mode 100644 index 0000000000..d8ba8d50a7 --- /dev/null +++ b/src/components/chat/tools/configs/toolConfigs.ask.test.tsx @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { QuestionAnswerContent } from '../components/ContentRenderers/QuestionAnswerContent'; + +import { getToolConfig } from './toolConfigs'; + +// gjc's interactive tool is lowercase `ask` (Claude's is AskUserQuestion). +// Regression: without a registered config it fell through to Default — a +// closed "Parameters" JSON block — so the question/options were invisible in +// the transcript view while the tmux TUI waited on the menu. + +const GJC_ASK_INPUT = { + questions: [ + { + id: 'pick', + question: 'A안과 B안 중 어느 쪽으로 진행할까요?\n\n상세 설명 줄은 제목에서 잘린다.', + options: [{ label: 'A안' }, { label: 'B안' }], + recommended: 0, + }, + ], +}; + +test('ask is registered with the question-answer renderer (not Default)', () => { + const config = getToolConfig('ask'); + assert.equal(config.input.contentType, 'question-answer'); + assert.equal(config.input.defaultOpen, true); +}); + +test('ask title: single question shows its first line, multi shows a count, malformed degrades', () => { + const title = getToolConfig('ask').input.title; + assert.equal(typeof title, 'function'); + const titleFn = title as (input: unknown) => string; + assert.equal(titleFn(GJC_ASK_INPUT), 'A안과 B안 중 어느 쪽으로 진행할까요?'); + assert.equal(titleFn({ questions: [{ question: 'q1' }, { question: 'q2' }] }), '2 questions'); + // Cold-spill / malformed arguments must not crash the row. + assert.equal(titleFn({ __gjcColdSpillArguments: true }), 'Question'); +}); + +test('ask content props render the gjc question and options end to end', () => { + const props = getToolConfig('ask').input.getContentProps?.(GJC_ASK_INPUT) as { + questions: never[]; + answers: Record; + }; + const html = renderToStaticMarkup(React.createElement(QuestionAnswerContent, props)); + assert.ok(html.includes('A안과 B안 중 어느 쪽으로 진행할까요?')); + assert.ok(html.includes('A안')); + assert.ok(html.includes('B안')); +}); diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts index 6a34b2cdd3..bf72996a7e 100644 --- a/src/components/chat/tools/configs/toolConfigs.ts +++ b/src/components/chat/tools/configs/toolConfigs.ts @@ -488,6 +488,44 @@ export const TOOL_CONFIGS: Record = { } }, + // gjc's interactive choice tool (lowercase `ask`). Without this entry it + // fell through to Default — a closed "Parameters" JSON block, i.e. the + // question and its options were effectively invisible in the transcript + // view while the tmux TUI sat waiting on the menu (실사고). Input shape: + // { questions: [{ id, question, options: [{ label }], recommended, multi }] } + // — options are already { label } objects, so QuestionAnswerContent takes + // them as-is. The user's pick arrives as a separate toolResult message and + // stays visible (no hideOnSuccess): in a read-only tmux transcript the + // answer chosen at the terminal is part of the story. + ask: { + input: { + type: 'collapsible', + title: (input: any) => { + const questions = Array.isArray(input?.questions) ? input.questions : []; + if (questions.length === 1 && typeof questions[0]?.question === 'string') { + const head = questions[0].question.split('\n')[0].trim(); + return head.length > 72 ? `${head.slice(0, 72)}…` : head || 'Question'; + } + return questions.length > 1 ? `${questions.length} questions` : 'Question'; + }, + defaultOpen: true, + contentType: 'question-answer', + getContentProps: (input: any) => ({ + questions: Array.isArray(input?.questions) ? input.questions : [], + answers: {} + }), + }, + result: { + type: 'collapsible', + title: 'Answer', + defaultOpen: true, + contentType: 'text', + getContentProps: (result: any) => ({ + content: String(result?.content || ''), + format: 'plain' + }) + } + }, // ============================================================================ // PLAN TOOLS // ============================================================================ diff --git a/src/components/chat/tools/liveAnswerContext.ts b/src/components/chat/tools/liveAnswerContext.ts new file mode 100644 index 0000000000..0e6a5945ae --- /dev/null +++ b/src/components/chat/tools/liveAnswerContext.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; + +export type LiveAnswerFn = (label: string) => Promise<{ ok: boolean; stale: boolean; detail: string }>; + +/** + * When a live (read-only) tmux gjc session is being viewed, this carries a + * channel to answer its on-screen ask-TUI menu by option label. Null in every + * other context (historical Claude/codex transcripts) — the ask card then + * renders read-only as before. The tower verifies the menu still shows the + * label before committing, so clicking a stale option fails closed (stale=true) + * rather than mis-selecting. + */ +export const LiveAnswerContext = createContext(null); + +export function useLiveAnswer(): LiveAnswerFn | null { + return useContext(LiveAnswerContext); +} diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index e328c75ee1..0becc0b74d 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -11,6 +11,9 @@ import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatComposerState } from '../hooks/useChatComposerState'; +import { LiveAnswerContext, type LiveAnswerFn } from '../tools/liveAnswerContext'; +import { api } from '../../../utils/api'; +import { requestLivePollBoost } from '../../../utils/livePollBoost'; import { useSessionStore } from '../../../stores/useSessionStore'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; @@ -310,6 +313,30 @@ function ChatInterface({ handlePermissionDecision, }), [pendingPermissionRequests, handlePermissionDecision]); + // Answering a live session's ask menu by option label. Non-null only when a + // live tmux target with a generation token is in view — the tower requires + // both and verifies the on-screen menu before committing (fail-closed). + const liveAnswer = useMemo(() => { + if (!liveSessionTmuxName || !liveSessionTmuxId) { + return null; + } + return async (label: string) => { + try { + const response = await api.liveSessionAnswer(liveSessionTmuxName, liveSessionTmuxId, label); + const body = await response.json().catch(() => null); + const data = (body?.data ?? body ?? {}) as { ok?: boolean; stale?: boolean; detail?: string }; + requestLivePollBoost(); + return { + ok: Boolean(response.ok && data.ok), + stale: response.status === 409 || Boolean(data.stale), + detail: typeof data.detail === 'string' ? data.detail : '', + }; + } catch { + return { ok: false, stale: false, detail: 'network error' }; + } + }; + }, [liveSessionTmuxName, liveSessionTmuxId]); + // Mirrors ChatComposer's own visibility check so the message pane can // reserve enough bottom space to keep the floating status tab from // overlapping the last message. @@ -339,7 +366,9 @@ function ChatInterface({ ); } + return ( +
+ ); } diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx index a8e26fb1d1..f397fc707b 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx @@ -1,6 +1,8 @@ -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 = | { kind: 'idle' } @@ -9,27 +11,74 @@ 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 + // token means we cannot prove which same-named session receives the text. + if (!tmuxId) { + setStatus({ kind: 'error', text: '세션 세대 정보 미확인 — 목록 갱신 후 다시 시도' }); return; } 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 }; @@ -44,7 +93,11 @@ 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. + requestLivePollBoost(); } catch { setStatus({ kind: 'error', text: '전송 실패' }); } @@ -67,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 = ''; + }} + /> +