diff --git a/package-lock.json b/package-lock.json index 1dd489c..221f36c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1142,9 +1142,9 @@ } }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3297,21 +3297,21 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -3321,6 +3321,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -4645,9 +4659,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4662,9 +4673,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4679,9 +4687,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4696,9 +4701,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4713,9 +4715,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4730,9 +4729,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4747,9 +4743,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4764,9 +4757,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4781,9 +4771,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4798,9 +4785,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4815,9 +4799,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4832,9 +4813,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4849,9 +4827,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6471,9 +6446,9 @@ } }, "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -6568,9 +6543,9 @@ } }, "node_modules/app-builder-lib/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -7438,9 +7413,9 @@ } }, "node_modules/builder-util/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -8985,9 +8960,9 @@ } }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9063,9 +9038,9 @@ } }, "node_modules/dmg-builder/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -17353,9 +17328,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "peer": true, diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 50850b3..57d2b9b 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -1,5 +1,6 @@ import express, { type Request, type Response } from 'express'; +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; @@ -7,8 +8,17 @@ import { providerModelsService } from '@/modules/providers/services/provider-mod import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js'; import { sessionsService } from '@/modules/providers/services/sessions.service.js'; +import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.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 { + getCurrentTmuxSessionName, + getExternalCliSessions, + killExternalCodexSession, + resolveCodexRolloutPath, + resolveExternalCodexCwd, + sendToExternalCodexSession, + spawnExternalCodexSession, +} 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 { listLiveGjcCommands } from '@/modules/providers/services/live-commands.service.js'; @@ -593,11 +603,138 @@ router.get( // pane's real claude/codex session from this lane. Such a name may then // legitimately appear in BOTH tabs — a gjc conversation row and an // attachable terminal row are different, both-true views. - const externalSessions = await getExternalCliSessions(); + const externalSessions = await Promise.all((await getExternalCliSessions()).map(async (session) => { + if (session.kind !== 'codex' || !session.codexThreadId) { + return session; + } + let appSession = sessionsDb.getSessionByProviderSessionId('codex', session.codexThreadId); + if (!appSession) { + const rolloutPath = await resolveCodexRolloutPath(session.codexThreadId); + if (rolloutPath) { + await sessionSynchronizerService.synchronizeProviderFile('codex', rolloutPath).catch(() => null); + appSession = sessionsDb.getSessionByProviderSessionId('codex', session.codexThreadId); + } + } + if (!appSession) { + return { tmuxName: session.tmuxName, kind: session.kind }; + } + return { + tmuxName: session.tmuxName, + kind: session.kind, + transcriptSessionId: appSession.session_id, + }; + })); res.json(createApiSuccessResponse({ externalSessions })); }), ); +router.post( + '/sessions/external/spawn', + asyncHandler(async (req: Request, res: Response) => { + const body = (req.body ?? {}) as { name?: unknown; cwd?: unknown }; + if (!isValidSpawnName(body.name)) { + throw new AppError('A valid session name is required (alphanumeric, not "company").', { + code: 'INVALID_SPAWN_NAME', + statusCode: 400, + }); + } + const cwdInput = typeof body.cwd === 'string' ? body.cwd.trim() : ''; + if (!cwdInput) { + throw new AppError('cwd is required.', { code: 'EMPTY_CWD', statusCode: 400 }); + } + const cwd = await resolveExternalCodexCwd(cwdInput); + if (!cwd) { + throw new AppError('cwd must be an existing directory under HOME.', { + code: 'INVALID_CWD', + statusCode: 400, + }); + } + try { + await spawnExternalCodexSession(body.name, cwd); + } catch { + throw new AppError('Codex session could not be created; the tmux name may already exist.', { + code: 'EXTERNAL_CODEX_SPAWN_FAILED', + statusCode: 409, + }); + } + res.status(201).json(createApiSuccessResponse({ ok: true, tmuxName: body.name, cwd })); + }), +); + +router.post( + '/sessions/external/kill', + asyncHandler(async (req: Request, res: Response) => { + const body = (req.body ?? {}) as { tmuxName?: unknown }; + if (!isValidTmuxName(body.tmuxName)) { + throw new AppError('A valid tmuxName is required.', { code: 'INVALID_TMUX_NAME', statusCode: 400 }); + } + if (body.tmuxName === await getCurrentTmuxSessionName()) { + throw new AppError('The tmux session hosting Gajae App is protected.', { + code: 'EXTERNAL_CODEX_SESSION_PROTECTED', + statusCode: 403, + }); + } + const target = (await getExternalCliSessions()).find( + (session) => session.tmuxName === body.tmuxName && session.kind === 'codex', + ); + if (!target) { + throw new AppError('The selected tmux session is no longer a running Codex session.', { + code: 'EXTERNAL_CODEX_SESSION_MISMATCH', + statusCode: 409, + }); + } + try { + await killExternalCodexSession(body.tmuxName); + } catch { + throw new AppError('The Codex tmux session could not be stopped.', { + code: 'EXTERNAL_CODEX_KILL_FAILED', + statusCode: 409, + }); + } + res.json(createApiSuccessResponse({ ok: true })); + }), +); + +router.post( + '/sessions/external/send', + asyncHandler(async (req: Request, res: Response) => { + const body = (req.body ?? {}) as { tmuxName?: unknown; sessionId?: unknown; message?: unknown }; + if (!isValidTmuxName(body.tmuxName)) { + throw new AppError('A valid tmuxName is required.', { code: 'INVALID_TMUX_NAME', statusCode: 400 }); + } + const sessionId = typeof body.sessionId === 'string' ? body.sessionId : null; + const message = typeof body.message === 'string' ? body.message : ''; + if (sessionId !== null && !SESSION_ID_PATTERN.test(sessionId)) { + throw new AppError('A valid sessionId is required.', { code: 'INVALID_SESSION_ID', statusCode: 400 }); + } + if (!message.trim()) { + throw new AppError('message is required.', { code: 'EMPTY_MESSAGE', statusCode: 400 }); + } + + const external = (await getExternalCliSessions()).find( + (session) => session.tmuxName === body.tmuxName && session.kind === 'codex', + ); + if (!external) { + throw new AppError('Codex tmux session changed; reopen it from External CLI.', { + code: 'EXTERNAL_CODEX_SESSION_MISMATCH', + statusCode: 409, + }); + } + const mapped = sessionId && external.codexThreadId + ? sessionsDb.getSessionByProviderSessionId('codex', external.codexThreadId) + : null; + if (sessionId && (!mapped || mapped.session_id !== sessionId)) { + throw new AppError('Codex tmux session changed; reopen it from External CLI.', { + code: 'EXTERNAL_CODEX_SESSION_MISMATCH', + statusCode: 409, + }); + } + + await sendToExternalCodexSession(body.tmuxName, message); + res.json(createApiSuccessResponse({ ok: true })); + }), +); + router.get( '/fs/dir-suggestions', asyncHandler(async (req: Request, res: Response) => { @@ -715,6 +852,34 @@ router.get( }), ); +router.get( + '/sessions/:sessionId', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = parseSessionId(req.params.sessionId); + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + const project = session.project_path + ? projectsDb.getProjectPath(session.project_path) + : null; + res.json(createApiSuccessResponse({ + session: { + sessionId: session.session_id, + provider: session.provider, + summary: session.custom_name ?? '', + projectId: project?.project_id ?? null, + projectPath: session.project_path, + createdAt: session.created_at, + updatedAt: session.updated_at, + }, + })); + }), +); + 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 6cd34f0..10a426a 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -1,4 +1,9 @@ import { spawn } from 'node:child_process'; +import { realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, sep } from 'node:path'; + +import Database from 'better-sqlite3'; /** * External CLI (claude / codex) tmux-session detection — the Termius-style lane. @@ -18,16 +23,62 @@ import { spawn } from 'node:child_process'; */ const TMUX_FIELD_SEP = '\t'; +const CODEX_THREAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const CODEX_RESUME_THREAD_RE = /(?:^|\s)resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?=\s|$)/i; export type ExternalCliKind = 'claude' | 'codex' | 'ssh'; -export type ExternalCliSession = { tmuxName: string; kind: ExternalCliKind }; +export type ExternalCliSession = { + tmuxName: string; + kind: ExternalCliKind; + codexThreadId?: string; +}; + +type FreshCodexProcess = { tmuxName: string; cwd: string; startedAtMs: number }; +type FreshCodexThread = { id: string; cwd: string; createdAtMs: number }; /** 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}$/; -/** 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 }> = []; +/** Extracts the native Codex thread id from `codex resume ` argv. */ +export function extractCodexResumeThreadId(processArgs: string | undefined): string | null { + return processArgs?.match(CODEX_RESUME_THREAD_RE)?.[1] ?? null; +} + +/** Assigns each newly-created native thread to the closest preceding tmux Codex process. */ +export function assignFreshCodexThreadIds( + processes: FreshCodexProcess[], + threads: FreshCodexThread[], + windowMs = 10 * 60 * 1000, +): Map { + const assigned = new Map(); + for (const thread of [...threads].sort((a, b) => a.createdAtMs - b.createdAtMs)) { + const eligible = processes.filter((process) => ( + !assigned.has(process.tmuxName) + && process.cwd === thread.cwd + && thread.createdAtMs >= process.startedAtMs - 5_000 + && thread.createdAtMs <= process.startedAtMs + windowMs + )); + const preceding = eligible + .filter((process) => process.startedAtMs <= thread.createdAtMs) + .sort((a, b) => b.startedAtMs - a.startedAtMs); + const owner = preceding[0] + ?? eligible.sort((a, b) => a.startedAtMs - b.startedAtMs)[0]; + if (owner) { + assigned.set(owner.tmuxName, thread.id); + } + } + return assigned; +} + +/** Parses pane identity plus the optional Gajae Codex transcript user-option. */ +export function parseExternalPanes(output: string): Array<{ + name: string; + pid: number; + command: string; + codexThreadId?: string; + cwd?: string; +}> { + const panes: Array<{ name: string; pid: number; command: string; codexThreadId?: string; cwd?: string }> = []; for (const raw of output.split(/\r?\n/)) { if (!raw.trim()) { continue; @@ -39,27 +90,47 @@ 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 third = raw.indexOf(TMUX_FIELD_SEP, second + 1); + const command = (third < 0 + ? raw.slice(second + 1) + : raw.slice(second + 1, third)).trim(); + const fourth = third < 0 ? -1 : raw.indexOf(TMUX_FIELD_SEP, third + 1); + const codexThreadId = third < 0 + ? '' + : (fourth < 0 ? raw.slice(third + 1) : raw.slice(third + 1, fourth)).trim(); + const cwd = fourth < 0 ? '' : raw.slice(fourth + 1).trim(); if (name && Number.isFinite(pid)) { - panes.push({ name, pid, command }); + panes.push({ + name, + pid, + command, + ...(codexThreadId ? { codexThreadId } : {}), + ...(cwd ? { cwd } : {}), + }); } } return panes; } -/** Parses `ps -eo pid,ppid,comm` output into {pid, ppid, comm} rows (header tolerated). */ -export function parsePsTree(output: string): Array<{ pid: number; ppid: number; comm: string }> { - const rows: Array<{ pid: number; ppid: number; comm: string }> = []; +/** Parses `ps -eo pid,ppid,comm[,args]` output (header tolerated). */ +export function parsePsTree(output: string): Array<{ pid: number; ppid: number; comm: string; args?: string }> { + const rows: Array<{ pid: number; ppid: number; comm: string; args?: string }> = []; for (const raw of output.split(/\r?\n/)) { const line = raw.trim(); if (!line) { continue; } - const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(line); + const match = /^(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?$/.exec(line); 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() }); + const processArgs = match[4]?.trim(); + rows.push({ + pid: Number.parseInt(match[1], 10), + ppid: Number.parseInt(match[2], 10), + comm: match[3], + ...(processArgs ? { args: processArgs } : {}), + }); } return rows; } @@ -77,8 +148,8 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; * safely). Output is sorted by name for stability. */ export function classifyExternalSessions(args: { - panes: Array<{ name: string; pid: number; command: string }>; - procs: Array<{ pid: number; ppid: number; comm: string }>; + panes: Array<{ name: string; pid: number; command: string; codexThreadId?: string; cwd?: string }>; + procs: Array<{ pid: number; ppid: number; comm: string; args?: string }>; }): ExternalCliSession[] { const children = new Map(); for (const proc of args.procs) { @@ -90,12 +161,16 @@ export function classifyExternalSessions(args: { } } const commByPid = new Map(); + const argsByPid = new Map(); for (const proc of args.procs) { commByPid.set(proc.pid, proc.comm); + if (proc.args) { + argsByPid.set(proc.pid, proc.args); + } } - const subtreeComms = (rootPid: number): Set => { - const comms = new Set(); + const subtreePids = (rootPid: number): number[] => { + const pids: number[] = []; const seen = new Set(); const queue: number[] = [rootPid]; while (queue.length > 0 && seen.size < 4096) { @@ -104,19 +179,17 @@ export function classifyExternalSessions(args: { continue; } seen.add(pid); - const comm = commByPid.get(pid); - if (comm) { - comms.add(comm); - } + pids.push(pid); for (const child of children.get(pid) ?? []) { queue.push(child); } } - return comms; + return pids; }; // Union comm sets per tmux session name. const commsBySession = new Map>(); + const codexThreadIdsBySession = new Map>(); for (const pane of args.panes) { let comms = commsBySession.get(pane.name); if (!comms) { @@ -126,8 +199,28 @@ export function classifyExternalSessions(args: { if (pane.command) { comms.add(pane.command); } - for (const comm of subtreeComms(pane.pid)) { - comms.add(comm); + for (const pid of subtreePids(pane.pid)) { + const comm = commByPid.get(pid); + if (comm) { + comms.add(comm); + } + const resumedThreadId = extractCodexResumeThreadId(argsByPid.get(pid)); + if (resumedThreadId) { + let threadIds = codexThreadIdsBySession.get(pane.name); + if (!threadIds) { + threadIds = new Set(); + codexThreadIdsBySession.set(pane.name, threadIds); + } + threadIds.add(resumedThreadId); + } + } + if (pane.codexThreadId && CODEX_THREAD_ID_RE.test(pane.codexThreadId)) { + let threadIds = codexThreadIdsBySession.get(pane.name); + if (!threadIds) { + threadIds = new Set(); + codexThreadIdsBySession.set(pane.name, threadIds); + } + threadIds.add(pane.codexThreadId); } } @@ -142,7 +235,12 @@ export function classifyExternalSessions(args: { if (comms.has('claude')) { result.push({ tmuxName: name, kind: 'claude' }); } else if (comms.has('codex')) { - result.push({ tmuxName: name, kind: 'codex' }); + const threadIds = [...(codexThreadIdsBySession.get(name) ?? [])]; + result.push({ + tmuxName: name, + kind: 'codex', + ...(threadIds.length === 1 ? { codexThreadId: threadIds[0] } : {}), + }); } else if (comms.has('ssh')) { // Remote lane: the pane tunnels into another machine, so the CLI running // there is invisible to local ps by definition (실측: company → ssh → @@ -170,12 +268,195 @@ function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promi child.on('error', (error) => { if (!settled) { settled = true; clearTimeout(timer); reject(error); } }); - child.on('close', () => { - if (!settled) { settled = true; clearTimeout(timer); resolve(stdout); } + child.on('close', (code) => { + if (!settled) { + settled = true; + clearTimeout(timer); + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`${command} exited with code ${code ?? 'unknown'}`)); + } + } }); }); } +async function processStartMs(pid: number): Promise { + try { + return (await stat(`/proc/${pid}`)).mtimeMs; + } catch { + return null; + } +} + +function descendants(rootPid: number, children: ReadonlyMap): number[] { + const result: number[] = []; + const seen = new Set(); + const queue = [rootPid]; + while (queue.length > 0 && seen.size < 4096) { + const pid = queue.shift()!; + if (seen.has(pid)) continue; + seen.add(pid); + result.push(pid); + queue.push(...(children.get(pid) ?? [])); + } + return result; +} + +function readFreshCodexThreads(minCreatedAtMs: number): FreshCodexThread[] { + let db: Database.Database | null = null; + try { + db = new Database(join(homedir(), '.codex', 'state_5.sqlite'), { + readonly: true, + fileMustExist: true, + }); + return db.prepare(` + SELECT id, cwd, COALESCE(created_at_ms, created_at * 1000) AS createdAtMs + FROM threads + WHERE source = 'cli' + AND thread_source = 'user' + AND COALESCE(created_at_ms, created_at * 1000) >= ? + ORDER BY COALESCE(created_at_ms, created_at * 1000) ASC + `).all(minCreatedAtMs - 5_000) as FreshCodexThread[]; + } catch { + return []; + } finally { + db?.close(); + } +} + +async function inferFreshCodexThreadIds(args: { + sessions: ExternalCliSession[]; + panes: ReturnType; + procs: ReturnType; +}): Promise> { + const unresolvedNames = new Set( + args.sessions + .filter((session) => session.kind === 'codex' && !session.codexThreadId) + .map((session) => session.tmuxName), + ); + if (unresolvedNames.size === 0) return new Map(); + + const children = new Map(); + const procByPid = new Map(args.procs.map((proc) => [proc.pid, proc])); + for (const proc of args.procs) { + const siblings = children.get(proc.ppid) ?? []; + siblings.push(proc.pid); + children.set(proc.ppid, siblings); + } + + const processes: FreshCodexProcess[] = []; + for (const pane of args.panes) { + if (!unresolvedNames.has(pane.name) || !pane.cwd) continue; + const codexPids = descendants(pane.pid, children).filter((pid) => { + const proc = procByPid.get(pid); + return proc?.comm === 'codex' + && !proc.args?.includes(' app-server') + && !proc.args?.includes('code-mode'); + }); + if (codexPids.length !== 1) continue; + const startedAtMs = await processStartMs(codexPids[0]); + if (startedAtMs !== null) { + processes.push({ tmuxName: pane.name, cwd: pane.cwd, startedAtMs }); + } + } + if (processes.length === 0) return new Map(); + + const threads = readFreshCodexThreads(Math.min(...processes.map((process) => process.startedAtMs))); + return assignFreshCodexThreadIds(processes, threads); +} + +/** Relays one literal prompt/selection into a verified native Codex tmux TUI. */ +export async function sendToExternalCodexSession(tmuxName: string, message: string): Promise { + const target = `${tmuxName}:`; + await runCommand('tmux', ['send-keys', '-t', target, '-l', '--', message]); + // Codex coalesces a rapid key burst as paste input. Sending Enter in the + // immediately following tmux command can land inside that paste window and + // leave the text sitting in the composer instead of submitting it. + await new Promise((resolve) => setTimeout(resolve, 150)); + await runCommand('tmux', ['send-keys', '-t', target, 'Enter']); +} + +/** Resolves a web spawn cwd and rejects traversal/symlink escape outside HOME. */ +export async function resolveExternalCodexCwd(input: string): Promise { + const home = await realpath(homedir()).catch(() => null); + if (!home || input.includes('\0')) return null; + const trimmed = input.trim(); + const expanded = trimmed === '~' + ? homedir() + : trimmed.startsWith('~/') + ? join(homedir(), trimmed.slice(2)) + : isAbsolute(trimmed) + ? trimmed + : join(homedir(), trimmed); + try { + const resolved = await realpath(expanded); + const rel = relative(home, resolved); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + return null; + } + return (await stat(resolved)).isDirectory() ? resolved : null; + } catch { + return null; + } +} + +export async function spawnExternalCodexSession(tmuxName: string, cwd: string): Promise { + await runCommand('tmux', ['new-session', '-d', '-s', tmuxName, '-c', cwd, 'codex']); +} + +export async function killExternalCodexSession(tmuxName: string): Promise { + await runCommand('tmux', ['kill-session', '-t', `=${tmuxName}`]); +} + +/** Returns the tmux session hosting this server, so its own session cannot be killed. */ +export async function getCurrentTmuxSessionName(): Promise { + const paneId = process.env.TMUX_PANE; + if (!paneId || !/^%\d+$/.test(paneId)) return null; + try { + return (await runCommand('tmux', [ + 'display-message', + '-p', + '-t', + paneId, + '#{session_name}', + ])).trim() || null; + } catch { + return null; + } +} + +/** Finds and validates Codex's JSONL rollout path for immediate transcript indexing. */ +export async function resolveCodexRolloutPath(threadId: string): Promise { + if (!CODEX_THREAD_ID_RE.test(threadId)) return null; + + let db: Database.Database | null = null; + try { + db = new Database(join(homedir(), '.codex', 'state_5.sqlite'), { + readonly: true, + fileMustExist: true, + }); + const row = db.prepare('SELECT rollout_path FROM threads WHERE id = ? LIMIT 1') + .get(threadId) as { rollout_path?: unknown } | undefined; + if (typeof row?.rollout_path !== 'string' || !row.rollout_path.endsWith('.jsonl')) { + return null; + } + + const [sessionsRoot, rolloutPath] = await Promise.all([ + realpath(join(homedir(), '.codex', 'sessions')), + realpath(row.rollout_path), + ]); + const rel = relative(sessionsRoot, rolloutPath); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; + return rolloutPath; + } catch { + return null; + } finally { + db?.close(); + } +} + /** * Returns external CLI (claude/codex) tmux sessions. Empty on any failure * (no tmux, ps error) — tmux/ps dependence is confined here. @@ -184,13 +465,19 @@ export async function getExternalCliSessions(): Promise { let tmuxOutput: string; let psOutput: 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']); + tmuxOutput = await runCommand('tmux', ['list-panes', '-a', '-F', `#{session_name}${TMUX_FIELD_SEP}#{pane_pid}${TMUX_FIELD_SEP}#{pane_current_command}${TMUX_FIELD_SEP}#{@gajae_codex_thread_id}${TMUX_FIELD_SEP}#{pane_current_path}`]); + psOutput = await runCommand('ps', ['-eo', 'pid,ppid,comm,args']); } catch { return []; } - return classifyExternalSessions({ - panes: parseExternalPanes(tmuxOutput), - procs: parsePsTree(psOutput), - }); + const panes = parseExternalPanes(tmuxOutput); + const procs = parsePsTree(psOutput); + const sessions = classifyExternalSessions({ panes, procs }); + const inferred = await inferFreshCodexThreadIds({ sessions, panes, procs }); + return sessions.map((session) => ({ + ...session, + ...(!session.codexThreadId && inferred.has(session.tmuxName) + ? { codexThreadId: inferred.get(session.tmuxName)! } + : {}), + })); } diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index f0cad90..f051791 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -79,14 +79,16 @@ const IDLE_TMUX_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; * True when a process's argv belongs to gjc — whether it runs as a native `gjc` * binary or under an interpreter (`bun /path/to/gjc`, `node …/coding-agent/…`). * The command NAME (comm) is unreliable for interpreter launches (it reads - * 'bun'/'node'), so argv is authoritative. `cmdline` is the raw NUL-separated - * /proc//cmdline. + * 'bun'/'node'), so argv is authoritative. `cmdline` may be raw NUL-separated + * /proc//cmdline or the whitespace-separated `ps args` representation. */ export function isGjcCommandLine(cmdline: string): boolean { if (!cmdline) { return false; } - const argv = cmdline.split('\0').filter(Boolean); + const argv = cmdline.includes('\0') + ? cmdline.split('\0').filter(Boolean) + : cmdline.trim().split(/\s+/).filter(Boolean); return argv.some((token) => basename(token) === 'gjc') || cmdline.includes('@gajae-code/coding-agent'); } @@ -453,6 +455,19 @@ export type RuntimeReceipt = { mtimeMs: number; }; +/** Parses the two-line receipt written by gjc 0.11+ for a tmux pane. */ +export function parseTerminalSessionReceipt(content: string, mtimeMs: number): RuntimeReceipt | null { + const [cwd, sessionFile] = content.split(/\r?\n/); + if (!cwd || !sessionFile) { + return null; + } + const match = SESSION_FILE_RE.exec(sessionFile); + if (!match) { + return null; + } + return { sessionId: match[1], cwd, sessionFile, mtimeMs }; +} + /** Pure pick: newest receipt for this pane, guarded by cwd match + pane-start floor. */ export function pickPaneReceipt(args: { paneCwd: string; @@ -517,6 +532,33 @@ async function readPaneRuntimeReceipts(paneCwd: string): Promise { + try { + const environment = await readFile(`/proc/${panePid}/environ`, 'utf8'); + const paneValue = environment + .split('\0') + .find((entry) => entry.startsWith('TMUX_PANE=')) + ?.slice('TMUX_PANE='.length); + if (!paneValue || !/^%\d+$/.test(paneValue)) { + return null; + } + const receiptPath = join(homedir(), '.gjc', 'agent', 'terminal-sessions', `tmux-${paneValue}`); + const [content, meta] = await Promise.all([readFile(receiptPath, 'utf8'), stat(receiptPath)]); + const receipt = parseTerminalSessionReceipt(content, meta.mtimeMs); + if (!receipt?.sessionFile) { + return null; + } + await stat(receipt.sessionFile); + return { + ...receipt, + cwd: receipt.cwd ? ((await safeRealpath(receipt.cwd)) ?? receipt.cwd) : null, + }; + } catch { + return null; + } +} + /** /proc/ dir mtime ≈ process start — the cheap stale-receipt floor. */ async function processStartMs(pid: number): Promise { try { @@ -854,10 +896,14 @@ async function scanLiveGjcSessions(): Promise { for (const idle of idlePanes) { let bound = false; for (const pane of panes.filter((candidate) => candidate.sid === idle.sid)) { + const terminalReceipt = await readPaneTerminalReceipt(pane.pid); const receipt = pickPaneReceipt({ paneCwd: pane.cwd, paneStartMs: await processStartMs(pane.pid), - receipts: await readPaneRuntimeReceipts(pane.cwd), + receipts: [ + ...(terminalReceipt ? [terminalReceipt] : []), + ...await readPaneRuntimeReceipts(pane.cwd), + ], }); if (!receipt || claimedIds.has(receipt.sessionId)) { continue; 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 86dacb0..fe2eed7 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -3,7 +3,9 @@ import test from 'node:test'; import { EXTERNAL_TMUX_NAME_RE, + assignFreshCodexThreadIds, classifyExternalSessions, + extractCodexResumeThreadId, parseExternalPanes, parsePsTree, } from '@/modules/providers/services/external-cli-sessions.service.js'; @@ -16,6 +18,52 @@ test('parseExternalPanes splits session_namepane_pidpane_current_comma ]); }); +test('parseExternalPanes reads transcript id and cwd from the extended tmux format', () => { + const out = parseExternalPanes( + 'native\t700\tnode\t\t/workspace\nmobile\t701\tnode\t019f848f-ff71-77f0-8623-08625d24f037\t/workspace/mobile\n', + ); + assert.deepEqual(out, [ + { name: 'native', pid: 700, command: 'node', cwd: '/workspace' }, + { + name: 'mobile', + pid: 701, + command: 'node', + codexThreadId: '019f848f-ff71-77f0-8623-08625d24f037', + cwd: '/workspace/mobile', + }, + ]); +}); + +test('assignFreshCodexThreadIds pairs fresh threads one-to-one by cwd and start time', () => { + const assigned = assignFreshCodexThreadIds( + [ + { tmuxName: 'first', cwd: '/workspace', startedAtMs: 1_000 }, + { tmuxName: 'second', cwd: '/workspace', startedAtMs: 2_000 }, + { tmuxName: 'other', cwd: '/other', startedAtMs: 1_500 }, + ], + [ + { id: 'thread-first', cwd: '/workspace', createdAtMs: 1_100 }, + { id: 'thread-other', cwd: '/other', createdAtMs: 1_600 }, + { id: 'thread-second', cwd: '/workspace', createdAtMs: 2_100 }, + { id: 'unrelated', cwd: '/missing', createdAtMs: 2_200 }, + ], + ); + assert.deepEqual([...assigned], [ + ['first', 'thread-first'], + ['other', 'thread-other'], + ['second', 'thread-second'], + ]); +}); + +test('assignFreshCodexThreadIds ignores threads outside the launch window', () => { + const assigned = assignFreshCodexThreadIds( + [{ tmuxName: 'late', cwd: '/workspace', startedAtMs: 1_000 }], + [{ id: 'too-late', cwd: '/workspace', createdAtMs: 2_001 }], + 1_000, + ); + assert.equal(assigned.size, 0); +}); + test('parsePsTree parses pid,ppid,comm rows and tolerates the header', () => { const out = parsePsTree(' PID PPID COMM\n 1 0 systemd\n 360992 1278 node\n1731394 1731329 codex\n'); assert.deepEqual(out, [ @@ -25,6 +73,37 @@ test('parsePsTree parses pid,ppid,comm rows and tolerates the header', () => { ]); }); +test('parsePsTree optionally preserves argv from ps args', () => { + const out = parsePsTree(' 700 100 bun bun /home/user/.bun/bin/gjc\n'); + assert.deepEqual(out, [ + { pid: 700, ppid: 100, comm: 'bun', args: 'bun /home/user/.bun/bin/gjc' }, + ]); +}); + +test('extractCodexResumeThreadId reads native `codex resume ` argv', () => { + assert.equal( + extractCodexResumeThreadId('node /home/user/bin/codex resume 019f7b07-3def-7501-a53f-f519c88dd722'), + '019f7b07-3def-7501-a53f-f519c88dd722', + ); + assert.equal(extractCodexResumeThreadId('codex --remote ws://127.0.0.1:4518'), null); +}); + +test('classifyExternalSessions auto-links a native Codex resume process to its transcript', () => { + const result = classifyExternalSessions({ + panes: [{ name: 'native', pid: 700, command: 'node' }], + procs: [ + { pid: 700, ppid: 1, comm: 'zsh', args: '-zsh' }, + { pid: 701, ppid: 700, comm: 'node', args: 'node /home/user/bin/codex resume 019f7b07-3def-7501-a53f-f519c88dd722' }, + { pid: 702, ppid: 701, comm: 'codex', args: '/vendor/codex resume 019f7b07-3def-7501-a53f-f519c88dd722' }, + ], + }); + assert.deepEqual(result, [{ + tmuxName: 'native', + kind: 'codex', + codexThreadId: '019f7b07-3def-7501-a53f-f519c88dd722', + }]); +}); + test('classifyExternalSessions: claude pane by pane_current_command (실측 shape)', () => { const result = classifyExternalSessions({ panes: [{ name: 'patina', pid: 113501, command: 'claude' }], @@ -46,6 +125,26 @@ test('classifyExternalSessions: codex surfaces as node pane + codex descendant ( assert.deepEqual(result, [{ tmuxName: 'test', kind: 'codex' }]); }); +test('classifyExternalSessions: tagged Codex pane exposes its transcript thread id', () => { + const result = classifyExternalSessions({ + panes: [{ + name: 'mobile', + pid: 700, + command: 'node', + codexThreadId: '019f848f-ff71-77f0-8623-08625d24f037', + }], + procs: [ + { pid: 700, ppid: 1, comm: 'node' }, + { pid: 701, ppid: 700, comm: 'codex' }, + ], + }); + assert.deepEqual(result, [{ + tmuxName: 'mobile', + kind: 'codex', + codexThreadId: '019f848f-ff71-77f0-8623-08625d24f037', + }]); +}); + test('classifyExternalSessions: gjc anywhere in the session excludes it (live lane contract)', () => { const result = classifyExternalSessions({ panes: [ diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index ff66413..5913237 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -11,6 +11,7 @@ import { isGjcProcessArgs, parsePsArgsTree, parseLastModelChange, + parseTerminalSessionReceipt, parseTurnActivity, parseLsofPidSessions, parseTmuxPanes, @@ -451,6 +452,22 @@ test('pickPaneReceipt tolerates a null receipt cwd (older gjc builds) but never assert.equal(pickPaneReceipt({ paneCwd: '/ws', paneStartMs: null, receipts })?.sessionId, 'null-cwd'); }); +test('parseTerminalSessionReceipt maps gjc 0.11 tmux receipt to its transcript id', () => { + assert.deepEqual( + parseTerminalSessionReceipt( + '/workspace/project\n/home/user/.gjc/agent/sessions/v2-scope/2026-07-17T17-28-17_019f711f-31f5-7000-9e7e-31f808b9ba71.jsonl\n', + 1234, + ), + { + sessionId: '019f711f-31f5-7000-9e7e-31f808b9ba71', + cwd: '/workspace/project', + sessionFile: '/home/user/.gjc/agent/sessions/v2-scope/2026-07-17T17-28-17_019f711f-31f5-7000-9e7e-31f808b9ba71.jsonl', + mtimeMs: 1234, + }, + ); + assert.equal(parseTerminalSessionReceipt('/workspace/project\n/not-a-session.txt\n', 1234), null); +}); + // ─── parseTurnActivity (턴 진행 중 판정 — RUN/LIVE 배지) ───────────────────── const turnLine = (role: string, stopReason?: string) => diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 678e3f8..142979d 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -92,35 +92,156 @@ function AppContentInner() { // selecting any project/session or starting a new chat clears it via the // wrapped sidebar handlers below. const [externalTerminal, setExternalTerminal] = useState(null); + const [externalTranscript, setExternalTranscript] = useState(null); const openExternalTerminal = useCallback((target: ExternalTerminalTarget) => { + if (target.cliKind !== 'gjc' && target.transcriptSessionId) { + setExternalTerminal(null); + setExternalTranscript(target); + setActiveTab('chat'); + navigate(`/session/${target.transcriptSessionId}`); + setSidebarOpen(false); + return; + } + setExternalTranscript(null); setExternalTerminal(target); setSidebarOpen(false); - }, [setSidebarOpen]); + }, [navigate, setActiveTab, setSidebarOpen]); const closeExternalTerminal = useCallback(() => { setExternalTerminal(null); }, []); + useEffect(() => { + if (externalTerminal?.cliKind !== 'codex' || externalTerminal.transcriptSessionId) return undefined; + let cancelled = false; + const poll = async () => { + try { + const response = await api.externalSessions(); + if (!response.ok || cancelled) return; + const body = await response.json(); + const sessions = body?.data?.externalSessions ?? []; + const ready = sessions.find((session: { tmuxName?: unknown; transcriptSessionId?: unknown }) => ( + session.tmuxName === externalTerminal.tmuxName + && typeof session.transcriptSessionId === 'string' + )); + if (!cancelled && ready) { + openExternalTerminal({ + ...externalTerminal, + transcriptSessionId: ready.transcriptSessionId, + }); + } + } catch { + // Best-effort: the sidebar poll can still complete the same handoff. + } + }; + void poll(); + const timer = window.setInterval(() => void poll(), 2000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [externalTerminal, openExternalTerminal]); + + useEffect(() => { + if (externalTerminal?.cliKind !== 'gjc') return undefined; + let cancelled = false; + let resolving = false; + const poll = async () => { + if (resolving) return; + try { + const response = await api.liveSessions(); + if (!response.ok || cancelled) return; + const body = await response.json(); + const sessions = (body?.data?.liveSessions ?? body?.liveSessions ?? []) as Array<{ + id?: unknown; + tmuxName?: unknown; + tmuxId?: unknown; + }>; + const ready = sessions.find((session) => ( + typeof session.id === 'string' + && !session.id.startsWith('idle-gjc:') + && session.tmuxName === externalTerminal.tmuxName + && (externalTerminal.tmuxId === null || session.tmuxId === externalTerminal.tmuxId) + )); + if (!ready || typeof ready.id !== 'string') return; + + resolving = true; + const detailsResponse = await api.sessionDetails(ready.id); + const detailsBody = await detailsResponse.json().catch(() => null); + const session = detailsBody?.data?.session as { + sessionId?: unknown; + provider?: unknown; + summary?: unknown; + projectId?: unknown; + createdAt?: unknown; + updatedAt?: unknown; + } | undefined; + const projectId = typeof session?.projectId === 'string' ? session.projectId : ''; + const project = sidebarSharedProps.projects.find((candidate) => candidate.projectId === projectId); + if ( + cancelled + || !detailsResponse.ok + || session?.sessionId !== ready.id + || session.provider !== 'gjc' + || !project + ) { + resolving = false; + return; + } + + setExternalTerminal(null); + setExternalTranscript(null); + setActiveTab('chat'); + sidebarSharedProps.onProjectSelect(project); + sidebarSharedProps.onSessionSelect({ + id: ready.id, + summary: typeof session.summary === 'string' ? session.summary : '', + createdAt: typeof session.createdAt === 'string' ? session.createdAt : undefined, + updated_at: typeof session.updatedAt === 'string' ? session.updatedAt : undefined, + __provider: 'gjc', + __projectId: project.projectId, + }); + } catch { + resolving = false; + // Best-effort: the next poll retries transcript discovery/indexing. + } + }; + void poll(); + const timer = window.setInterval(() => void poll(), 2000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [externalTerminal, setActiveTab, sidebarSharedProps]); + // Wrap navigation-ish sidebar handlers so leaving for a session/project/new // chat drops the terminal takeover — without modifying the originals. const sidebarProps = useMemo(() => ({ ...sidebarSharedProps, onProjectSelect: (...args: Parameters) => { setExternalTerminal(null); + setExternalTranscript(null); return sidebarSharedProps.onProjectSelect(...args); }, onSessionSelect: (...args: Parameters) => { setExternalTerminal(null); + setExternalTranscript(null); return sidebarSharedProps.onSessionSelect(...args); }, onNewSession: (...args: Parameters) => { setExternalTerminal(null); + setExternalTranscript(null); return sidebarSharedProps.onNewSession(...args); }, onExternalTerminalOpen: openExternalTerminal, }), [sidebarSharedProps, openExternalTerminal]); + const activeExternalTranscript = externalTranscript?.cliKind !== 'gjc' + && externalTranscript?.transcriptSessionId === sessionId + ? externalTranscript + : null; + // Queued messages for sessions that finish while another session (or none) // is being viewed are sent from here; the viewed session's composer handles // its own queue. @@ -280,13 +401,16 @@ function AppContentInner() { void; onFileOpen?: (filePath: string, diffInfo?: any) => void; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index e53cb82..b42fff9 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -25,6 +25,7 @@ function ChatInterface({ liveSessionTmuxName, liveSessionTmuxId, liveSessionModel, + liveSessionKind, ws, sendMessage, onFileOpen, @@ -389,6 +390,7 @@ function ChatInterface({ showRawParameters={showRawParameters} showThinking={showThinking} selectedProject={selectedProject} + transcriptView={liveSessionKind === 'codex'} />
@@ -417,7 +419,15 @@ function ChatInterface({ liveSessionTmuxName ? ( // key: remount per tmux target — a draft/in-flight status typed // for session A must never survive a switch to target B (리뷰 반영). - + ) : (
diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 23ffaef..fd9e3f3 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -65,6 +65,7 @@ interface ChatMessagesPaneProps { showRawParameters?: boolean; showThinking?: boolean; selectedProject: Project; + transcriptView?: boolean; } function ChatMessagesPane({ @@ -113,6 +114,7 @@ function ChatMessagesPane({ showRawParameters, showThinking, selectedProject, + transcriptView = false, }: ChatMessagesPaneProps) { const { t } = useTranslation('chat'); const groupedVisibleMessages = useMemo( @@ -265,6 +267,7 @@ function ChatMessagesPane({ showThinking={showThinking} selectedProject={selectedProject} provider={provider} + transcriptView={transcriptView} /> ); } @@ -285,6 +288,7 @@ function ChatMessagesPane({ showThinking={showThinking} selectedProject={selectedProject} provider={provider} + transcriptView={transcriptView} /> ); }); diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx index 0d62f9c..44b0254 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx @@ -81,11 +81,15 @@ export default function LiveRelayComposer({ tmuxId = null, model = null, workspacePath = null, + relayKind = 'gjc', + sessionId = null, }: { tmuxName: string; tmuxId?: string | null; model?: string | null; workspacePath?: string | null; + relayKind?: 'gjc' | 'codex'; + sessionId?: string | null; }) { const [input, setInput] = useState(''); const [status, setStatus] = useState({ kind: 'idle' }); @@ -100,6 +104,10 @@ export default function LiveRelayComposer({ // Load the invokable slash commands for this session once per target. Failure // (no gjc home / tower / commands) degrades silently to a plain relay box. useEffect(() => { + if (relayKind !== 'gjc') { + setCommands([]); + return undefined; + } let cancelled = false; void (async () => { try { @@ -119,7 +127,7 @@ export default function LiveRelayComposer({ return () => { cancelled = true; }; - }, [workspacePath]); + }, [workspacePath, relayKind]); const closeCommandMenu = useCallback(() => { setShowCommandMenu(false); @@ -176,7 +184,9 @@ export default function LiveRelayComposer({ } setStatus({ kind: 'sending' }); try { - const response = await api.liveSessionSend(tmuxName, message, tmuxId); + const response = relayKind === 'codex' + ? await api.externalCodexSessionSend(tmuxName, sessionId, message) + : 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 }; // ok === false covers "tower reachable but refused/failed" (server wraps a @@ -194,7 +204,7 @@ export default function LiveRelayComposer({ } catch { setStatus({ kind: 'error', text: '전송 실패' }); } - }, [input, status.kind, tmuxName, tmuxId]); + }, [input, status.kind, tmuxName, tmuxId, relayKind, sessionId]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { @@ -267,7 +277,7 @@ export default function LiveRelayComposer({ onKeyDown={handleKeyDown} onClick={(event) => syncCommandMenu(input, event.currentTarget.selectionStart ?? input.length)} rows={1} - placeholder={`${tmuxName}에 지시… ( / 명령, Enter 전송, Shift+Enter 줄바꿈)`} + placeholder={`${tmuxName}에 지시… (${relayKind === 'gjc' ? ' / 명령, ' : ''}Enter 전송, Shift+Enter 줄바꿈)`} className="max-h-40 min-h-9 flex-1 resize-none bg-transparent px-2 py-1.5 text-sm outline-none" /> + )} + + {externalTerminal.tmuxName} + + {isGjc ? 'GJC transcript 준비 중' : 'Codex transcript 준비 중'} + +
+ +
+
+
+ 첫 지시를 보내면 {isGjc ? 'GJC' : 'Codex'} transcript가 생성되어 이 화면에 자동으로 연결됩니다. +
+
+ +
+ ); + } + // External CLI (claude/codex) tmux terminal takes over the whole main area — // same footprint as a gjc session. Rendered before the no-project empty state // because the target carries its own project (PTY cwd only). @@ -259,6 +311,7 @@ function MainContent({ liveSessionTmuxName={liveSessionTmuxName} liveSessionTmuxId={liveSessionTmuxId} liveSessionModel={liveSessionModel} + liveSessionKind={liveSessionKind} ws={ws} sendMessage={sendMessage} onFileOpen={handleFileOpen} diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts index d368753..ad438a2 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.ts @@ -2,17 +2,22 @@ import { useEffect, useState } from 'react'; import { api } from '../../../utils/api'; -export type ExternalCliSession = { tmuxName: string; kind: 'claude' | 'codex' | 'ssh' }; +export type ExternalCliSession = { + tmuxName: string; + kind: 'claude' | 'codex' | 'ssh'; + transcriptSessionId?: string; +}; -const POLL_INTERVAL_MS = 10000; +const POLL_INTERVAL_MS = 5000; /** - * Polls /sessions/external (10s, best-effort) for claude/codex tmux sessions. + * Polls /sessions/external (5s, best-effort) for claude/codex tmux sessions. * Self-contained so the gjc live lane (useProjectsState's live poll) stays * untouched; gjc sessions are excluded server-side. [] on any failure. */ -export function useExternalCliSessions(): ExternalCliSession[] { +export function useExternalCliSessions(): { sessions: ExternalCliSession[]; refresh: () => void } { const [sessions, setSessions] = useState([]); + const [refreshToken, setRefreshToken] = useState(0); useEffect(() => { let cancelled = false; @@ -41,7 +46,10 @@ export function useExternalCliSessions(): ExternalCliSession[] { cancelled = true; clearInterval(timer); }; - }, []); + }, [refreshToken]); - return sessions; + return { + sessions, + refresh: () => setRefreshToken((value) => value + 1), + }; } diff --git a/src/components/sidebar/view/subcomponents/SidebarCodexSpawnSession.tsx b/src/components/sidebar/view/subcomponents/SidebarCodexSpawnSession.tsx new file mode 100644 index 0000000..1083487 --- /dev/null +++ b/src/components/sidebar/view/subcomponents/SidebarCodexSpawnSession.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; + +import HomeDirInput from '../../../../shared/view/HomeDirInput'; +import { api } from '../../../../utils/api'; + +type SpawnStatus = 'idle' | 'spawning' | 'error'; + +export default function SidebarCodexSpawnSession({ onCreated }: { onCreated: () => void }) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [cwd, setCwd] = useState(''); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + + const close = () => { + setOpen(false); + setName(''); + setCwd(''); + setStatus('idle'); + setError(''); + }; + + const spawn = async () => { + const trimmedName = name.trim(); + const trimmedCwd = cwd.trim(); + if (!trimmedName || !trimmedCwd || status === 'spawning') return; + + setStatus('spawning'); + setError(''); + try { + const response = await api.externalCodexSessionSpawn(trimmedName, trimmedCwd); + const body = await response.json().catch(() => null); + if (response.ok && body?.data?.ok) { + close(); + onCreated(); + return; + } + setError(body?.error?.message ?? body?.message ?? 'Codex 세션 생성 실패'); + } catch { + setError('Codex 세션 생성 실패'); + } + setStatus('error'); + }; + + if (!open) { + return ( +
+ +
+ ); + } + + return ( +
+ setName(event.target.value)} + placeholder="tmux 세션 이름 (예: codex-work)" + className="w-full rounded-md border border-border bg-transparent px-2 py-1.5 text-sm outline-none focus:border-emerald-500/60" + /> + void spawn()} + placeholder="작업 폴더 (홈 하위)" + /> + {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 0f1cd94..43a5dcc 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -17,6 +17,7 @@ import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProje import SidebarLiveSection from './SidebarLiveSection'; import SidebarExternalSection from './SidebarExternalSection'; import SidebarSpawnSession from './SidebarSpawnSession'; +import SidebarCodexSpawnSession from './SidebarCodexSpawnSession'; function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { const parts: ReactNode[] = []; @@ -205,7 +206,7 @@ export default function SidebarContent({ t, }: SidebarContentProps) { const [topTab, setTopTab] = useState<'live' | 'external' | 'archive'>('live'); - const externalSessions = useExternalCliSessions(); + const { sessions: externalSessions, refresh: refreshExternalSessions } = useExternalCliSessions(); const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2; const hasPartialResults = conversationResults && conversationResults.results.length > 0; const groupedArchivedSessions = groupArchivedSessionsByProject(archivedSessions); @@ -291,18 +292,26 @@ export default function SidebarContent({ projects={projects} liveSessionIds={projectListProps.liveSessionIds} selectedSession={projectListProps.selectedSession} + onProjectSelect={projectListProps.onProjectSelect} onSessionSelect={projectListProps.onSessionSelect} liveSessionNames={liveSessionNames} liveSessionLineage={liveSessionLineage} liveSessionTmuxIds={liveSessionTmuxIds} liveSessionKinds={liveSessionKinds} liveSessionRunning={liveSessionRunning} + onExternalTerminalOpen={onExternalTerminalOpen} /> )} ) : topTab === 'external' ? ( - + + ) : ( diff --git a/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx b/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx index 0014594..cf41994 100644 --- a/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx @@ -1,6 +1,8 @@ -import { SquareTerminal } from 'lucide-react'; +import { Fragment, useEffect, useRef, useState } from 'react'; +import { MessageSquare, SquareTerminal, X } from 'lucide-react'; import type { ExternalTerminalTarget, Project } from '../../../../types/app'; +import { api } from '../../../../utils/api'; import type { ExternalCliSession } from '../../hooks/useExternalCliSessions'; const KIND_LABEL: Record = { @@ -20,6 +22,7 @@ type SidebarExternalSectionProps = { projects: Project[]; /** Opens the session as a full main-area terminal (like gjc sessions do). */ onOpen: (target: ExternalTerminalTarget) => void; + onChanged: () => void; }; /** @@ -28,10 +31,65 @@ type SidebarExternalSectionProps = { * which renders it as a full main-area terminal (Termius-style attach) — * mirroring how gjc sessions fill the right side. */ -export default function SidebarExternalSection({ sessions, projects, onOpen }: SidebarExternalSectionProps) { +export default function SidebarExternalSection({ sessions, projects, onOpen, onChanged }: SidebarExternalSectionProps) { + const [confirming, setConfirming] = useState(null); + const [killing, setKilling] = useState(null); + const [error, setError] = useState(''); + const pendingTranscriptRef = useRef(null); // Shell needs a real project only for the PTY cwd; attach ignores the cwd. const shellProject = projects[0] ?? null; + const openSession = (session: ExternalCliSession) => { + if (!shellProject) return; + pendingTranscriptRef.current = session.kind === 'codex' && !session.transcriptSessionId + ? session.tmuxName + : null; + onOpen({ + tmuxName: session.tmuxName, + kind: KIND_LABEL[session.kind], + cliKind: session.kind, + project: shellProject, + transcriptSessionId: session.transcriptSessionId, + }); + }; + + useEffect(() => { + const tmuxName = pendingTranscriptRef.current; + if (!tmuxName || !shellProject) return; + const session = sessions.find((candidate) => ( + candidate.tmuxName === tmuxName && candidate.transcriptSessionId + )); + if (!session) return; + pendingTranscriptRef.current = null; + onOpen({ + tmuxName: session.tmuxName, + kind: KIND_LABEL[session.kind], + cliKind: session.kind, + project: shellProject, + transcriptSessionId: session.transcriptSessionId, + }); + }, [onOpen, sessions, shellProject]); + + const stopCodex = async (tmuxName: string) => { + if (killing) return; + setKilling(tmuxName); + setError(''); + try { + const response = await api.externalCodexSessionKill(tmuxName); + const body = await response.json().catch(() => null); + if (response.ok && body?.data?.ok) { + setConfirming(null); + onChanged(); + return; + } + setError(body?.error?.message ?? body?.message ?? 'Codex 세션 종료 실패'); + } catch { + setError('Codex 세션 종료 실패'); + } finally { + setKilling(null); + } + }; + if (sessions.length === 0 || !shellProject) { return (
@@ -42,23 +100,62 @@ export default function SidebarExternalSection({ sessions, projects, onOpen }: S return (
+ {error &&

{error}

} {sessions.map((session) => ( - + +
+ + {session.kind === 'codex' && ( + + )} +
+ {confirming === session.tmuxName && ( +
+ 이 Codex 세션을 종료할까요? + + +
+ )} +
))}
); diff --git a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx index 96f7655..4ec23f6 100644 --- a/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarLiveSection.test.tsx @@ -10,6 +10,7 @@ import SidebarLiveSection from './SidebarLiveSection'; const noop = () => {}; const onSessionSelect = noop as unknown as (session: ProjectSession, projectName: string) => void; +const onProjectSelect = noop as unknown as (project: Project) => void; function makeProjects(): Project[] { return [ @@ -35,6 +36,7 @@ test('SidebarLiveSection labels rows by tmux session name, title in tooltip', () liveSessionKinds: new Map([['s-live', 'interactive']]), liveSessionRunning: new Set(), selectedSession: null, + onProjectSelect, onSessionSelect, }), ); @@ -56,6 +58,7 @@ test('SidebarLiveSection falls back to the conversation title when tmux name is liveSessionKinds: new Map(), liveSessionRunning: new Set(), selectedSession: null, + onProjectSelect, onSessionSelect, }), ); @@ -73,6 +76,7 @@ test('SidebarLiveSection renders nothing when no session is live', () => { liveSessionKinds: new Map(), liveSessionRunning: new Set(), selectedSession: null, + onProjectSelect, onSessionSelect, }), ); @@ -90,13 +94,15 @@ test('SidebarLiveSection renders idle-gjc rows as 대기 (첫 대화 전 gjc pan liveSessionKinds: new Map([['idle-gjc:flask', 'interactive']]), liveSessionRunning: new Set(), selectedSession: null, + onProjectSelect, onSessionSelect, }), ); 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 pre-transcript state'); + assert.ok(html.includes('눌러서 첫 대화 시작'), 'opens a full pending transcript view'); + assert.ok(html.includes("tmux 세션 'flask'에서 첫 대화 시작"), 'the idle row itself is clickable'); assert.ok(html.includes('첫 메시지 보내기'), 'idle lineage rows offer the first-message composer'); assert.ok(html.includes('tmux 세션 flask 닫기'), 'lineage-grade idle rows keep the kill control'); }); @@ -112,6 +118,7 @@ test('SidebarLiveSection badges a batch gjc row (foreground command is not gjc)' liveSessionKinds: new Map([['s-live', 'batch']]), liveSessionRunning: new Set(), selectedSession: null, + onProjectSelect, onSessionSelect, }), ); @@ -134,6 +141,7 @@ test('SidebarLiveSection badges an in-progress turn as RUN, not LIVE', () => { liveSessionKinds: new Map([['s-live', 'interactive']]), liveSessionRunning: new Set(['s-live']), selectedSession: null, + onProjectSelect, onSessionSelect, }), ); @@ -141,3 +149,23 @@ test('SidebarLiveSection badges an in-progress turn as RUN, not LIVE', () => { assert.ok(!html.includes('>LIVE<'), 'the same row does not also show LIVE'); assert.ok(html.includes('emerald'), 'RUN is styled green, not blue'); }); + +test('SidebarLiveSection makes transcript-backed orphan rows directly openable', () => { + const html = renderToStaticMarkup( + createElement(SidebarLiveSection, { + projects: makeProjects(), + liveSessionIds: new Set(['s-resumed']), + liveSessionNames: new Map([['s-resumed', 'resume-pane']]), + liveSessionLineage: new Set(), + liveSessionTmuxIds: new Map(), + liveSessionKinds: new Map([['s-resumed', 'interactive']]), + liveSessionRunning: new Set(), + selectedSession: null, + onProjectSelect, + onSessionSelect, + }), + ); + assert.ok(html.includes('; selectedSession: ProjectSession | null; + onProjectSelect: SidebarProjectListProps['onProjectSelect']; onSessionSelect: SidebarProjectListProps['onSessionSelect']; + onExternalTerminalOpen?: (target: ExternalTerminalTarget) => void; }; /** Per-row kill flow state (2-step confirm before the tower is asked to kill). */ @@ -72,12 +74,16 @@ export default function SidebarLiveSection({ liveSessionKinds, liveSessionRunning, selectedSession, + onProjectSelect, onSessionSelect, + onExternalTerminalOpen, }: 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 [openingId, setOpeningId] = useState(null); + const [openError, setOpenError] = 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 @@ -129,6 +135,51 @@ export default function SidebarLiveSection({ }); }; + const openOrphan = async (sessionId: string) => { + if (openingId) return; + setOpeningId(sessionId); + setOpenError((previous) => { + const next = new Map(previous); + next.delete(sessionId); + return next; + }); + try { + const response = await api.sessionDetails(sessionId); + const body = await response.json().catch(() => null); + const session = body?.data?.session as { + sessionId?: unknown; + provider?: unknown; + summary?: unknown; + projectId?: unknown; + createdAt?: unknown; + updatedAt?: unknown; + } | undefined; + if (!response.ok || session?.sessionId !== sessionId || session.provider !== 'gjc') { + throw new Error(body?.error?.message ?? '이전 대화를 불러오지 못했습니다'); + } + const projectId = typeof session.projectId === 'string' ? session.projectId : ''; + const project = projects.find((candidate) => candidate.projectId === projectId); + if (!project) { + throw new Error('연결된 프로젝트를 찾지 못했습니다. 목록을 새로고침해 주세요.'); + } + onProjectSelect(project); + onSessionSelect({ + id: sessionId, + summary: typeof session.summary === 'string' ? session.summary : '', + createdAt: typeof session.createdAt === 'string' ? session.createdAt : undefined, + updated_at: typeof session.updatedAt === 'string' ? session.updatedAt : undefined, + __provider: 'gjc', + }, project.projectId); + } catch (error) { + setOpenError((previous) => new Map(previous).set( + sessionId, + error instanceof Error ? error.message : '이전 대화를 불러오지 못했습니다', + )); + } finally { + setOpeningId(null); + } + }; + const kill = async (sessionId: string, tmuxName: string) => { setStatusOf(sessionId, { kind: 'killing' }); try { @@ -292,40 +343,71 @@ 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 content = ( + <> + + + + {isIdle ? '대기' : liveSessionRunning.has(id) ? 'RUN' : 'LIVE'} + + {liveSessionKinds.get(id) === 'batch' && ( + + 배치 + + )} + + {tmuxName ?? '이름 미확인 세션'} + + + + {isIdle + ? '눌러서 첫 대화 시작' + : openingId === id + ? '이전 대화 불러오는 중…' + : '눌러서 이전 대화 열기'} + + + ); return (
-
- - - - {isIdle ? '대기' : liveSessionRunning.has(id) ? 'RUN' : 'LIVE'} - - {liveSessionKinds.get(id) === 'batch' && ( - - 배치 - - )} - - {tmuxName ?? '이름 미확인 세션'} - - - - {isIdle - ? '아직 대화가 없습니다 — 아래에서 첫 메시지를 보낼 수 있습니다' - : '대화 미로딩 — 해당 프로젝트를 열면 제목이 표시됩니다'} - -
+ {isIdle ? ( + + ) : ( + + )} {tmuxName && liveSessionLineage.has(id) && killButton(id, tmuxName)}
+ {openError.has(id) && ( +

{openError.get(id)}

+ )} {isIdle && tmuxName && liveSessionLineage.has(id) && ( + authenticatedFetch('/api/providers/sessions/external/send', { + method: 'POST', + body: JSON.stringify({ tmuxName, sessionId, message }), + }), + // Create/stop native Codex tmux sessions directly from the External CLI tab. + externalCodexSessionSpawn: (name, cwd) => + authenticatedFetch('/api/providers/sessions/external/spawn', { + method: 'POST', + body: JSON.stringify({ name, cwd }), + }), + externalCodexSessionKill: (tmuxName) => + authenticatedFetch('/api/providers/sessions/external/kill', { + method: 'POST', + body: JSON.stringify({ tmuxName }), + }), // Spawn a new tmux gjc session via the control tower (POST /spawn). liveSessionSpawn: (name, cwd) => authenticatedFetch('/api/providers/sessions/live/spawn', { @@ -135,6 +152,10 @@ export const api = { const qs = params.toString(); return authenticatedFetch(`/api/providers/sessions/live/commands${qs ? `?${qs}` : ''}`); }, + // Minimal persisted metadata used to open a live transcript even when its + // project session page has not reached that older row yet. + sessionDetails: (sessionId) => + authenticatedFetch(`/api/providers/sessions/${encodeURIComponent(sessionId)}`), // External CLI (claude/codex) tmux sessions for the terminal-attach lane. externalSessions: () => authenticatedFetch('/api/providers/sessions/external'), // Home-relative directory autocomplete ({ home, suggestions }).