From f2ec7774431d5eb1a02de082294ed9ddcbee2326 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Feb 2026 18:07:39 -0800 Subject: [PATCH 01/13] Track running terminal ports across terminal activity events - add `runningPorts` to terminal activity contract and validation - detect/listen ports in server subprocess inspection and emit activity updates on port changes - persist terminal-to-port state in web store and show active ports in sidebar indicator - update server/web/contracts tests for port-aware activity behavior --- apps/server/src/terminalManager.test.ts | 41 +++- apps/server/src/terminalManager.ts | 269 +++++++++++++++++++++--- apps/web/src/App.tsx | 1 + apps/web/src/components/Sidebar.tsx | 52 ++++- apps/web/src/persistenceSchema.test.ts | 1 + apps/web/src/persistenceSchema.ts | 1 + apps/web/src/store.test.ts | 25 ++- apps/web/src/store.ts | 44 +++- apps/web/src/types.ts | 1 + apps/web/src/worktreeCleanup.test.ts | 1 + packages/contracts/src/terminal.test.ts | 1 + packages/contracts/src/terminal.ts | 1 + 12 files changed, 386 insertions(+), 52 deletions(-) diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 55b11068be6..3f092946f0b 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -144,6 +144,9 @@ describe("TerminalManager", () => { options: { shellResolver?: () => string; subprocessChecker?: (terminalPid: number) => Promise; + subprocessInspector?: ( + terminalPid: number, + ) => Promise<{ hasRunningSubprocess: boolean; runningPorts: number[] }>; subprocessPollIntervalMs?: number; } = {}, ) { @@ -156,6 +159,7 @@ describe("TerminalManager", () => { historyLineLimit, shellResolver: options.shellResolver ?? (() => "/bin/bash"), ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), + ...(options.subprocessInspector ? { subprocessInspector: options.subprocessInspector } : {}), ...(options.subprocessPollIntervalMs ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), @@ -289,9 +293,12 @@ describe("TerminalManager", () => { }); it("emits subprocess activity events when child-process state changes", async () => { - let hasRunningSubprocess = false; + let activity = { + hasRunningSubprocess: false, + runningPorts: [] as number[], + }; const { manager } = makeManager(5, { - subprocessChecker: async () => hasRunningSubprocess, + subprocessInspector: async () => activity, subprocessPollIntervalMs: 20, }); const events: TerminalEvent[] = []; @@ -303,17 +310,39 @@ describe("TerminalManager", () => { await waitFor(() => events.some((event) => event.type === "started")); expect(events.some((event) => event.type === "activity")).toBe(false); - hasRunningSubprocess = true; + activity = { hasRunningSubprocess: true, runningPorts: [3000] }; await waitFor( () => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.runningPorts.join(",") === "3000", + ), 1_200, ); - hasRunningSubprocess = false; + activity = { hasRunningSubprocess: true, runningPorts: [5173, 3000] }; await waitFor( () => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.runningPorts.join(",") === "3000,5173", + ), + 1_200, + ); + + activity = { hasRunningSubprocess: false, runningPorts: [5173] }; + await waitFor( + () => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === false && + event.runningPorts.length === 0, + ), 1_200, ); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index b6fb31af3bb..e3f44bb976f 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -27,8 +27,16 @@ const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const MAX_PORT_NUMBER = 65_535; type TerminalSubprocessChecker = (terminalPid: number) => Promise; +interface TerminalSubprocessActivity { + hasRunningSubprocess: boolean; + runningPorts: number[]; +} +type TerminalSubprocessInspector = ( + terminalPid: number, +) => Promise; export interface TerminalManagerEvents { event: [event: TerminalEvent]; @@ -40,6 +48,7 @@ export interface TerminalManagerOptions { ptyAdapter?: PtyAdapter; shellResolver?: () => string; subprocessChecker?: TerminalSubprocessChecker; + subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; } @@ -59,6 +68,7 @@ interface TerminalSessionState { unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; + runningSubprocessPorts: number[]; } function defaultShellResolver(): string { @@ -125,11 +135,62 @@ function isRetryableShellSpawnError(error: unknown): boolean { ); } -async function checkWindowsSubprocessActivity(terminalPid: number): Promise { +function normalizeRunningPorts(ports: number[]): number[] { + if (ports.length === 0) return []; + return [...new Set(ports)] + .filter((port) => Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) + .toSorted((left, right) => left - right); +} + +function parsePidList(stdout: string): number[] { + const pids: number[] = []; + for (const line of stdout.split(/\r?\n/g)) { + const pid = Number(line.trim()); + if (!Number.isInteger(pid) || pid <= 0) { + continue; + } + pids.push(pid); + } + return [...new Set(pids)]; +} + +function parsePortList(stdout: string): number[] { + const ports: number[] = []; + for (const line of stdout.split(/\r?\n/g)) { + const port = Number(line.trim()); + if (!Number.isInteger(port)) { + continue; + } + ports.push(port); + } + return normalizeRunningPorts(ports); +} + +function portFromAddress(address: string): number | null { + const match = address.match(/:(\d+)$/); + if (!match?.[1]) return null; + const port = Number(match[1]); + if (!Number.isInteger(port) || port <= 0 || port > MAX_PORT_NUMBER) { + return null; + } + return port; +} + +function arePortListsEqual(left: number[], right: number[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +async function collectWindowsChildPids(terminalPid: number): Promise { const command = [ `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, - "if ($children) { exit 0 }", - "exit 1", + "if (-not $children) { exit 0 }", + "$children | Select-Object -ExpandProperty ProcessId", ].join("; "); try { const result = await runProcess( @@ -142,30 +203,47 @@ async function checkWindowsSubprocessActivity(terminalPid: number): Promise { +async function checkWindowsListeningPorts(processIds: number[]): Promise { + if (processIds.length === 0) return []; + const processFilter = processIds + .map((pid) => `$_.OwningProcess -eq ${pid}`) + .join(" -or "); + const command = [ + "$connections = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue", + `$matching = $connections | Where-Object { ${processFilter} }`, + "if (-not $matching) { exit 0 }", + "$matching | Select-Object -ExpandProperty LocalPort -Unique", + ].join("; "); try { - const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { - timeoutMs: 1_000, - allowNonZeroExit: true, - maxBufferBytes: 32_768, - outputMode: "truncate", - }); - if (pgrepResult.code === 0) { - return pgrepResult.stdout.trim().length > 0; - } - if (pgrepResult.code === 1) { - return false; + const result = await runProcess( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", command], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 65_536, + outputMode: "truncate", + }, + ); + if (result.code !== 0) { + return []; } + return parsePortList(result.stdout); } catch { - // Fall back to ps when pgrep is unavailable. + return []; } +} +async function collectPosixProcessFamilyPids(terminalPid: number): Promise { try { const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { timeoutMs: 1_000, @@ -174,32 +252,134 @@ async function checkPosixSubprocessActivity(terminalPid: number): Promise(); for (const line of psResult.stdout.split(/\r?\n/g)) { const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); const pid = Number(pidRaw); const ppid = Number(ppidRaw); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - return true; + const children = childrenByParentPid.get(ppid); + if (children) { + children.push(pid); + } else { + childrenByParentPid.set(ppid, [pid]); } } - return false; + + const processFamily = new Set([terminalPid]); + const pendingParents = [terminalPid]; + while (pendingParents.length > 0) { + const parentPid = pendingParents.shift(); + if (!parentPid) continue; + const childPids = childrenByParentPid.get(parentPid); + if (!childPids || childPids.length === 0) continue; + for (const childPid of childPids) { + if (processFamily.has(childPid)) continue; + processFamily.add(childPid); + pendingParents.push(childPid); + } + } + + return [...processFamily]; + } catch { + return []; + } +} + +async function checkPosixListeningPorts(processIds: number[]): Promise { + if (processIds.length === 0) return []; + + const ports = new Set(); + const pidFilter = new Set(processIds); + + try { + const result = await runProcess( + "lsof", + ["-nP", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 262_144, + outputMode: "truncate", + }, + ); + if (result.code === 0) { + for (const line of result.stdout.split(/\r?\n/g)) { + const match = line.match(/:(\d+)\s+\(LISTEN\)$/); + if (!match?.[1]) continue; + const port = Number(match[1]); + if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) { + ports.add(port); + } + } + return [...ports].toSorted((left, right) => left - right); + } + } catch { + // Fall back to ss if lsof is unavailable. + } + + try { + const result = await runProcess("ss", ["-ltnp"], { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 524_288, + outputMode: "truncate", + }); + if (result.code !== 0) { + return []; + } + + for (const line of result.stdout.split(/\r?\n/g)) { + if (!line.includes("pid=")) continue; + const localAddress = line.trim().split(/\s+/g)[3]; + if (!localAddress) continue; + const port = portFromAddress(localAddress); + if (port === null) continue; + + const pidMatches = [...line.matchAll(/pid=(\d+)/g)]; + if (pidMatches.length === 0) continue; + if ( + pidMatches.some((match) => { + const pid = Number(match[1]); + return Number.isInteger(pid) && pidFilter.has(pid); + }) + ) { + ports.add(port); + } + } + return [...ports].toSorted((left, right) => left - right); } catch { - return false; + return []; } } -async function defaultSubprocessChecker(terminalPid: number): Promise { +async function defaultSubprocessInspector( + terminalPid: number, +): Promise { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return false; + return { hasRunningSubprocess: false, runningPorts: [] }; } + if (process.platform === "win32") { - return checkWindowsSubprocessActivity(terminalPid); + const childPids = await collectWindowsChildPids(terminalPid); + if (childPids.length === 0) { + return { hasRunningSubprocess: false, runningPorts: [] }; + } + const runningPorts = await checkWindowsListeningPorts(childPids); + return { hasRunningSubprocess: true, runningPorts }; } - return checkPosixSubprocessActivity(terminalPid); + + const processFamilyPids = await collectPosixProcessFamilyPids(terminalPid); + const subprocessPids = processFamilyPids.filter((pid) => pid !== terminalPid); + if (subprocessPids.length === 0) { + return { hasRunningSubprocess: false, runningPorts: [] }; + } + + const runningPorts = await checkPosixListeningPorts(subprocessPids); + return { hasRunningSubprocess: true, runningPorts }; } function capHistory(history: string, maxLines: number): string { @@ -262,7 +442,7 @@ export class TerminalManager extends EventEmitter { private readonly pendingPersistHistory = new Map(); private readonly threadLocks = new Map>(); private readonly persistDebounceMs: number; - private readonly subprocessChecker: TerminalSubprocessChecker; + private readonly subprocessInspector: TerminalSubprocessInspector; private readonly subprocessPollIntervalMs: number; private subprocessPollTimer: ReturnType | null = null; private subprocessPollInFlight = false; @@ -275,7 +455,14 @@ export class TerminalManager extends EventEmitter { this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); this.shellResolver = options.shellResolver ?? defaultShellResolver; this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; - this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; + this.subprocessInspector = + options.subprocessInspector ?? + (options.subprocessChecker + ? async (terminalPid: number) => ({ + hasRunningSubprocess: await options.subprocessChecker!(terminalPid), + runningPorts: [], + }) + : defaultSubprocessInspector); this.subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; fs.mkdirSync(this.logsDir, { recursive: true }); @@ -307,6 +494,7 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + runningSubprocessPorts: [], }; this.sessions.set(sessionKey, session); this.startSession(session, input, "started"); @@ -404,6 +592,7 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + runningSubprocessPorts: [], }; this.sessions.set(sessionKey, session); } else { @@ -474,6 +663,7 @@ export class TerminalManager extends EventEmitter { session.exitCode = null; session.exitSignal = null; session.hasRunningSubprocess = false; + session.runningSubprocessPorts = []; session.updatedAt = new Date().toISOString(); let ptyProcess: PtyProcess | null = null; @@ -540,6 +730,7 @@ export class TerminalManager extends EventEmitter { session.pid = null; session.process = null; session.hasRunningSubprocess = false; + session.runningSubprocessPorts = []; session.updatedAt = new Date().toISOString(); this.updateSubprocessPollingState(); const message = error instanceof Error ? error.message : "Terminal start failed"; @@ -577,6 +768,7 @@ export class TerminalManager extends EventEmitter { session.process = null; session.pid = null; session.hasRunningSubprocess = false; + session.runningSubprocessPorts = []; session.status = "exited"; session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; @@ -599,6 +791,7 @@ export class TerminalManager extends EventEmitter { session.process = null; session.pid = null; session.hasRunningSubprocess = false; + session.runningSubprocessPorts = []; session.status = "exited"; session.updatedAt = new Date().toISOString(); try { @@ -817,9 +1010,12 @@ export class TerminalManager extends EventEmitter { await Promise.all( runningSessions.map(async (session) => { const terminalPid = session.pid; - let hasRunningSubprocess = false; + let activity: TerminalSubprocessActivity = { + hasRunningSubprocess: false, + runningPorts: [], + }; try { - hasRunningSubprocess = await this.subprocessChecker(terminalPid); + activity = await this.subprocessInspector(terminalPid); } catch (error) { this.logger.warn("failed to check terminal subprocess activity", { threadId: session.threadId, @@ -834,11 +1030,19 @@ export class TerminalManager extends EventEmitter { if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { return; } - if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { + const hasRunningSubprocess = activity.hasRunningSubprocess === true; + const runningPorts = hasRunningSubprocess + ? normalizeRunningPorts(activity.runningPorts) + : []; + if ( + liveSession.hasRunningSubprocess === hasRunningSubprocess && + arePortListsEqual(liveSession.runningSubprocessPorts, runningPorts) + ) { return; } liveSession.hasRunningSubprocess = hasRunningSubprocess; + liveSession.runningSubprocessPorts = runningPorts; liveSession.updatedAt = new Date().toISOString(); this.emitEvent({ type: "activity", @@ -846,6 +1050,7 @@ export class TerminalManager extends EventEmitter { terminalId: liveSession.terminalId, createdAt: new Date().toISOString(), hasRunningSubprocess, + runningPorts, }); }), ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fbb1177bf1b..1ade5ad8928 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -99,6 +99,7 @@ function AutoProjectBootstrap() { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 60392d00429..a285d386b62 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -48,9 +48,10 @@ interface ThreadStatusPill { } interface TerminalStatusIndicator { - label: "Terminal process running"; + label: string; colorClass: string; pulse: boolean; + portsLabel: string | null; } function hasUnseenCompletion(thread: Thread): boolean { @@ -108,10 +109,27 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null if (thread.runningTerminalIds.length === 0) { return null; } + + const runningPorts = [...new Set( + thread.runningTerminalIds.flatMap( + (terminalId) => thread.runningTerminalPorts[terminalId] ?? [], + ), + )] + .filter((port) => Number.isInteger(port) && port > 0 && port <= 65_535) + .toSorted((left, right) => left - right); + + const label = + runningPorts.length === 1 + ? `Terminal process running on port ${runningPorts[0]}` + : runningPorts.length > 1 + ? `Terminal process running on ports ${runningPorts.join(", ")}` + : "Terminal process running"; + return { - label: "Terminal process running", + label, colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, + portsLabel: runningPorts.length > 0 ? runningPorts.join(", ") : null, }; } @@ -153,6 +171,7 @@ export default function Sidebar() { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { @@ -526,15 +545,26 @@ export default function Sidebar() {
{terminalStatus && ( - - + + + + + {terminalStatus.portsLabel && ( + + {terminalStatus.portsLabel} + + )} )} diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 70f4e9e5fd1..5580b685b33 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -271,6 +271,7 @@ describe("toPersistedState", () => { terminalHeight: 320, terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId: "term-2", terminalGroups: [ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index 544816e73c3..1ecfe00dd59 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -247,6 +247,7 @@ function hydrateThread( terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: safeTerminalIds, runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId, terminalGroups: normalizedGroups, activeTerminalGroupId, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 9bc2c3de669..8b9a39def97 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -42,6 +42,7 @@ function makeThread(overrides: Partial = {}): Thread { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { @@ -111,6 +112,7 @@ function makeTerminalActivityEvent( terminalId: DEFAULT_THREAD_TERMINAL_ID, createdAt: "2026-02-09T00:00:02.000Z", hasRunningSubprocess: true, + runningPorts: [3000], ...overrides, }; } @@ -303,6 +305,7 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.terminalOpen).toBe(false); expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); expect(next.threads[0]?.runningTerminalIds).toEqual([]); + expect(next.threads[0]?.runningTerminalPorts).toEqual({}); expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); expect(next.threads[0]?.terminalGroups).toEqual([ { @@ -325,14 +328,26 @@ describe("store reducer thread continuity", () => { event: makeTerminalActivityEvent(), }); expect(active.threads[0]?.runningTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(active.threads[0]?.runningTerminalPorts).toEqual({ + [DEFAULT_THREAD_TERMINAL_ID]: [3000], + }); + + const activeWithPortChange = reducer(active, { + type: "APPLY_TERMINAL_EVENT", + event: makeTerminalActivityEvent({ runningPorts: [5173, 3000] }), + }); + expect(activeWithPortChange.threads[0]?.runningTerminalPorts).toEqual({ + [DEFAULT_THREAD_TERMINAL_ID]: [3000, 5173], + }); - const idle = reducer(active, { + const idle = reducer(activeWithPortChange, { type: "APPLY_TERMINAL_EVENT", event: makeTerminalActivityEvent({ hasRunningSubprocess: false }), }); expect(idle.threads[0]?.runningTerminalIds).toEqual([]); + expect(idle.threads[0]?.runningTerminalPorts).toEqual({}); - const exited = reducer(active, { + const exited = reducer(activeWithPortChange, { type: "APPLY_TERMINAL_EVENT", event: { type: "exited", @@ -344,6 +359,7 @@ describe("store reducer thread continuity", () => { }, }); expect(exited.threads[0]?.runningTerminalIds).toEqual([]); + expect(exited.threads[0]?.runningTerminalPorts).toEqual({}); }); it("keeps running status when another terminal in the thread is still running", () => { @@ -351,6 +367,10 @@ describe("store reducer thread continuity", () => { makeThread({ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + runningTerminalPorts: { + [DEFAULT_THREAD_TERMINAL_ID]: [3000], + "term-2": [5173], + }, activeTerminalId: "term-2", terminalGroups: [ { @@ -375,6 +395,7 @@ describe("store reducer thread continuity", () => { }); expect(next.threads[0]?.runningTerminalIds).toEqual(["term-2"]); + expect(next.threads[0]?.runningTerminalPorts).toEqual({ "term-2": [5173] }); }); it("backfills codexThreadId from routed provider events", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 2f0ad22b6f0..4b4cbdaf16b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -157,6 +157,29 @@ function normalizeRunningTerminalIds( .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); } +function normalizeRunningPorts(ports: number[]): number[] { + if (ports.length === 0) return []; + return [...new Set(ports)] + .filter((port) => Number.isInteger(port) && port > 0 && port <= 65_535) + .toSorted((left, right) => left - right); +} + +function normalizeRunningTerminalPorts( + runningTerminalPorts: Record, + terminalIds: string[], +): Record { + const validTerminalIdSet = new Set(terminalIds); + const normalizedEntries: Array<[string, number[]]> = []; + for (const [rawTerminalId, ports] of Object.entries(runningTerminalPorts)) { + const terminalId = rawTerminalId.trim(); + if (terminalId.length === 0 || !validTerminalIdSet.has(terminalId)) { + continue; + } + normalizedEntries.push([terminalId, normalizeRunningPorts(ports)]); + } + return Object.fromEntries(normalizedEntries); +} + function normalizeTerminalGroupIds(terminalIds: string[]): string[] { return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; } @@ -257,6 +280,10 @@ function normalizeThreadTerminals(thread: Thread): Thread { ...thread, terminalIds, runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds), + runningTerminalPorts: normalizeRunningTerminalPorts( + thread.runningTerminalPorts, + terminalIds, + ), activeTerminalId, terminalGroups, activeTerminalGroupId, @@ -276,6 +303,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { terminalOpen: false, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { @@ -317,6 +345,9 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { ...thread, terminalIds: remainingTerminalIds, runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId), + runningTerminalPorts: Object.fromEntries( + Object.entries(thread.runningTerminalPorts).filter(([key]) => key !== terminalId), + ), activeTerminalId: nextActiveTerminalId, terminalGroups: nextTerminalGroups, }); @@ -683,21 +714,32 @@ export function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.event.threadId, (thread) => { const normalizedThread = normalizeThreadTerminals(thread); const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds); - if (action.event.type === "started" || action.event.type === "restarted") { + const runningTerminalPorts = { ...normalizedThread.runningTerminalPorts }; + if ( + action.event.type === "started" || + action.event.type === "restarted" + ) { runningTerminalIdSet.delete(action.event.terminalId); + delete runningTerminalPorts[action.event.terminalId]; } else if (action.event.type === "activity") { if (action.event.hasRunningSubprocess) { runningTerminalIdSet.add(action.event.terminalId); + runningTerminalPorts[action.event.terminalId] = normalizeRunningPorts( + action.event.runningPorts, + ); } else { runningTerminalIdSet.delete(action.event.terminalId); + delete runningTerminalPorts[action.event.terminalId]; } } else if (action.event.type === "exited" || action.event.type === "error") { runningTerminalIdSet.delete(action.event.terminalId); + delete runningTerminalPorts[action.event.terminalId]; } return normalizeThreadTerminals({ ...normalizedThread, runningTerminalIds: [...runningTerminalIdSet], + runningTerminalPorts, }); }), }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d662997231e..552516d03c7 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -49,6 +49,7 @@ export interface Thread { terminalHeight: number; terminalIds: string[]; runningTerminalIds: string[]; + runningTerminalPorts: Record; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 606717a0287..1aae2bbb7f1 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -14,6 +14,7 @@ function makeThread(overrides: Partial = {}): Thread { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], + runningTerminalPorts: {}, activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 8704ee1a533..a7666c945c5 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -147,6 +147,7 @@ describe("terminalEventSchema", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), hasRunningSubprocess: true, + runningPorts: [3000], }); expect(result.success).toBe(true); }); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 3b1d82e447b..061ddd38a99 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -89,6 +89,7 @@ export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({ export const terminalActivityEventSchema = terminalEventBaseSchema.extend({ type: z.literal("activity"), hasRunningSubprocess: z.boolean(), + runningPorts: z.array(z.number().int().min(1).max(65_535)).default([]), }); export const terminalEventSchema = z.discriminatedUnion("type", [ From 94a83ab48e3f30d1f0edcec4b09c2589a8410981 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Feb 2026 18:12:21 -0800 Subject: [PATCH 02/13] Require all lsof filters when checking listening ports - Add `-a` to the `lsof` command in terminal port detection - Prevent mismatched results by combining TCP listen and PID filters correctly --- apps/server/src/terminalManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index e3f44bb976f..2ffbedfcba4 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -298,7 +298,7 @@ async function checkPosixListeningPorts(processIds: number[]): Promise try { const result = await runProcess( "lsof", - ["-nP", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")], + ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")], { timeoutMs: 1_500, allowNonZeroExit: true, From 3d082fc73b9e9bc95b021a68cda3381750ee0705 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Feb 2026 18:32:27 -0800 Subject: [PATCH 03/13] Detect real web ports and add one-click localhost shortcut - Probe terminal subprocess ports and keep only likely web servers - Cache/dedupe port probes and ignore root-path 404 responses - Show a globe status icon in Sidebar that opens `http://localhost:` --- apps/server/src/terminalManager.test.ts | 69 +++++++++- apps/server/src/terminalManager.ts | 174 +++++++++++++++++++++++- apps/web/src/components/Sidebar.tsx | 84 ++++++++---- 3 files changed, 298 insertions(+), 29 deletions(-) diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 3f092946f0b..7266c8cf3b5 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; @@ -147,6 +148,7 @@ describe("TerminalManager", () => { subprocessInspector?: ( terminalPid: number, ) => Promise<{ hasRunningSubprocess: boolean; runningPorts: number[] }>; + webPortInspector?: (port: number) => Promise; subprocessPollIntervalMs?: number; } = {}, ) { @@ -160,6 +162,7 @@ describe("TerminalManager", () => { shellResolver: options.shellResolver ?? (() => "/bin/bash"), ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessInspector ? { subprocessInspector: options.subprocessInspector } : {}), + ...(options.webPortInspector ? { webPortInspector: options.webPortInspector } : {}), ...(options.subprocessPollIntervalMs ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), @@ -299,6 +302,7 @@ describe("TerminalManager", () => { }; const { manager } = makeManager(5, { subprocessInspector: async () => activity, + webPortInspector: async (port) => port === 3000, subprocessPollIntervalMs: 20, }); const events: TerminalEvent[] = []; @@ -322,14 +326,14 @@ describe("TerminalManager", () => { 1_200, ); - activity = { hasRunningSubprocess: true, runningPorts: [5173, 3000] }; + activity = { hasRunningSubprocess: true, runningPorts: [5173] }; await waitFor( () => events.some( (event) => event.type === "activity" && event.hasRunningSubprocess === true && - event.runningPorts.join(",") === "3000,5173", + event.runningPorts.length === 0, ), 1_200, ); @@ -349,6 +353,67 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("ignores ports that return HTTP 404 at root during web-port detection", async () => { + const notFoundServer = createServer((_req, res) => { + res.statusCode = 404; + res.end(); + }); + await new Promise((resolve, reject) => { + notFoundServer.listen(0, "127.0.0.1", (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const address = notFoundServer.address(); + const port = typeof address === "object" && address ? address.port : 0; + expect(port).toBeGreaterThan(0); + + const events: TerminalEvent[] = []; + let activity = { + hasRunningSubprocess: false, + runningPorts: [] as number[], + }; + const { manager } = makeManager(5, { + subprocessInspector: async () => activity, + subprocessPollIntervalMs: 20, + }); + manager.on("event", (event) => { + events.push(event); + }); + + try { + await manager.open(openInput()); + await waitFor(() => events.some((event) => event.type === "started")); + + activity = { hasRunningSubprocess: true, runningPorts: [port] }; + await waitFor( + () => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.runningPorts.length === 0, + ), + 1_500, + ); + } finally { + manager.dispose(); + await new Promise((resolve, reject) => { + notFoundServer.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); + it("caps persisted history to configured line limit", async () => { const { manager, ptyAdapter } = makeManager(3); await manager.open(openInput()); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 2ffbedfcba4..f4ee6b904a2 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; +import { request as httpRequest } from "node:http"; import path from "node:path"; import { @@ -27,9 +28,13 @@ const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS = 500; +const DEFAULT_WEB_PORT_PROBE_TTL_MS = 10_000; +const WEB_PORT_PROBE_MAX_BODY_BYTES = 8_192; const MAX_PORT_NUMBER = 65_535; type TerminalSubprocessChecker = (terminalPid: number) => Promise; +type TerminalWebPortInspector = (port: number) => Promise; interface TerminalSubprocessActivity { hasRunningSubprocess: boolean; runningPorts: number[]; @@ -49,6 +54,8 @@ export interface TerminalManagerOptions { shellResolver?: () => string; subprocessChecker?: TerminalSubprocessChecker; subprocessInspector?: TerminalSubprocessInspector; + webPortInspector?: TerminalWebPortInspector; + webPortProbeCacheTtlMs?: number; subprocessPollIntervalMs?: number; } @@ -382,6 +389,115 @@ async function defaultSubprocessInspector( return { hasRunningSubprocess: true, runningPorts }; } +interface WebProbeResult { + status: number; + contentType: string; + body: string; + location: string; +} + +function normalizeHeaderValue(value: string | string[] | undefined): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value[0] ?? ""; + return ""; +} + +function isLikelyWebProbe(result: WebProbeResult | null): boolean { + if (!result) return false; + if (result.status === 404) return false; + if (result.status >= 300 && result.status < 400 && result.location.length > 0) { + return true; + } + const contentType = result.contentType.toLowerCase(); + if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) { + return true; + } + const body = result.body.toLowerCase(); + return ( + body.includes(" { + return new Promise((resolve) => { + let timer: ReturnType | null = null; + let settled = false; + const settle = (result: WebProbeResult | null) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + } + resolve(result); + }; + + const req = httpRequest( + { + host, + port, + method: "GET", + path: "/", + timeout: DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS, + }, + (res) => { + const chunks: string[] = []; + let received = 0; + res.setEncoding("utf8"); + res.on("data", (chunk: string) => { + if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) return; + const remaining = WEB_PORT_PROBE_MAX_BODY_BYTES - received; + const fragment = chunk.slice(0, remaining); + received += fragment.length; + chunks.push(fragment); + if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) { + res.destroy(); + } + }); + res.on("end", () => { + settle({ + status: res.statusCode ?? 0, + contentType: normalizeHeaderValue(res.headers["content-type"]), + location: normalizeHeaderValue(res.headers.location), + body: chunks.join(""), + }); + }); + res.on("error", () => { + settle(null); + }); + }, + ); + + req.on("timeout", () => { + req.destroy(); + settle(null); + }); + req.on("error", () => { + settle(null); + }); + + timer = setTimeout(() => { + req.destroy(); + settle(null); + }, DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS + 50); + + req.end(); + }); +} + +async function defaultWebPortInspector(port: number): Promise { + const ipv4Result = await probeWebPortOnHost(port, "127.0.0.1"); + if (isLikelyWebProbe(ipv4Result)) { + return true; + } + const ipv6Result = await probeWebPortOnHost(port, "::1"); + return isLikelyWebProbe(ipv6Result); +} + function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); @@ -443,6 +559,13 @@ export class TerminalManager extends EventEmitter { private readonly threadLocks = new Map>(); private readonly persistDebounceMs: number; private readonly subprocessInspector: TerminalSubprocessInspector; + private readonly webPortInspector: TerminalWebPortInspector; + private readonly webPortProbeCacheTtlMs: number; + private readonly webPortProbeCache = new Map< + number, + { isWeb: boolean; checkedAt: number } + >(); + private readonly webPortProbeInFlight = new Map>(); private readonly subprocessPollIntervalMs: number; private subprocessPollTimer: ReturnType | null = null; private subprocessPollInFlight = false; @@ -463,6 +586,9 @@ export class TerminalManager extends EventEmitter { runningPorts: [], }) : defaultSubprocessInspector); + this.webPortInspector = options.webPortInspector ?? defaultWebPortInspector; + this.webPortProbeCacheTtlMs = + options.webPortProbeCacheTtlMs ?? DEFAULT_WEB_PORT_PROBE_TTL_MS; this.subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; fs.mkdirSync(this.logsDir, { recursive: true }); @@ -647,6 +773,8 @@ export class TerminalManager extends EventEmitter { this.pendingPersistHistory.clear(); this.threadLocks.clear(); this.persistQueues.clear(); + this.webPortProbeCache.clear(); + this.webPortProbeInFlight.clear(); } private startSession( @@ -993,6 +1121,48 @@ export class TerminalManager extends EventEmitter { this.subprocessPollTimer = null; } + private async inspectWebPortCached(port: number): Promise { + const now = Date.now(); + const cached = this.webPortProbeCache.get(port); + if (cached && now - cached.checkedAt <= this.webPortProbeCacheTtlMs) { + return cached.isWeb; + } + + const inFlight = this.webPortProbeInFlight.get(port); + if (inFlight) { + return inFlight; + } + + const probe = this.webPortInspector(port) + .then((isWeb) => { + this.webPortProbeCache.set(port, { + isWeb, + checkedAt: Date.now(), + }); + return isWeb; + }) + .catch(() => false) + .finally(() => { + this.webPortProbeInFlight.delete(port); + }); + this.webPortProbeInFlight.set(port, probe); + return probe; + } + + private async detectWebPorts(runningPorts: number[]): Promise { + if (runningPorts.length === 0) return []; + const checks = await Promise.all( + runningPorts.map(async (port) => ({ + port, + isWeb: await this.inspectWebPortCached(port), + })), + ); + return checks + .filter((entry) => entry.isWeb) + .map((entry) => entry.port) + .toSorted((left, right) => left - right); + } + private async pollSubprocessActivity(): Promise { if (this.subprocessPollInFlight) return; @@ -1032,7 +1202,9 @@ export class TerminalManager extends EventEmitter { } const hasRunningSubprocess = activity.hasRunningSubprocess === true; const runningPorts = hasRunningSubprocess - ? normalizeRunningPorts(activity.runningPorts) + ? normalizeRunningPorts( + await this.detectWebPorts(normalizeRunningPorts(activity.runningPorts)), + ) : []; if ( liveSession.hasRunningSubprocess === hasRunningSubprocess && diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a285d386b62..aed7909a84c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ -import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { GlobeIcon, MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; +import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isElectron } from "../env"; @@ -51,7 +51,7 @@ interface TerminalStatusIndicator { label: string; colorClass: string; pulse: boolean; - portsLabel: string | null; + primaryWebPort: number | null; } function hasUnseenCompletion(thread: Thread): boolean { @@ -119,17 +119,20 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null .toSorted((left, right) => left - right); const label = - runningPorts.length === 1 - ? `Terminal process running on port ${runningPorts[0]}` - : runningPorts.length > 1 - ? `Terminal process running on ports ${runningPorts.join(", ")}` - : "Terminal process running"; + runningPorts.length === 0 + ? "Terminal process running" + : runningPorts.length === 1 + ? `Open web server: http://localhost:${runningPorts[0]}` + : `Open web server: http://localhost:${runningPorts[0]} (detected web ports: ${runningPorts.join(", ")})`; return { label, - colorClass: "text-teal-600 dark:text-teal-300/90", + colorClass: + runningPorts.length >= 1 + ? "text-sky-600 dark:text-sky-300/90" + : "text-teal-600 dark:text-teal-300/90", pulse: true, - portsLabel: runningPorts.length > 0 ? runningPorts.join(", ") : null, + primaryWebPort: runningPorts[0] ?? null, }; } @@ -398,6 +401,16 @@ export default function Sidebar() { [api, dispatch, state.projects, state.threads], ); + const openWebPort = useCallback( + (event: MouseEvent, port: number) => { + event.preventDefault(); + event.stopPropagation(); + if (!api) return; + void api.shell.openExternal(`http://localhost:${port}`).catch(() => undefined); + }, + [api], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (!isChatNewShortcut(event, keybindings)) return; @@ -545,24 +558,43 @@ export default function Sidebar() {
{terminalStatus && ( - - - - - {terminalStatus.portsLabel && ( + + {terminalStatus.primaryWebPort !== null ? ( + + openWebPort(event, terminalStatus.primaryWebPort!) + } + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + if (!api) return; + void api.shell + .openExternal( + `http://localhost:${terminalStatus.primaryWebPort!}`, + ) + .catch(() => undefined); + }} + > + + + ) : ( - {terminalStatus.portsLabel} + )} From b3e967e2cff3280a800d30d047a02fd9de5e64dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Feb 2026 20:44:51 -0800 Subject: [PATCH 04/13] adapters --- apps/server/src/terminalManager.ts | 388 +----------------- .../src/terminalProcessInspector/index.ts | 55 +++ .../src/terminalProcessInspector/posix.ts | 119 ++++++ .../src/terminalProcessInspector/types.ts | 12 + .../src/terminalProcessInspector/utils.ts | 52 +++ .../src/terminalProcessInspector/win32.ts | 60 +++ apps/server/src/webPortInspector.ts | 114 +++++ 7 files changed, 424 insertions(+), 376 deletions(-) create mode 100644 apps/server/src/terminalProcessInspector/index.ts create mode 100644 apps/server/src/terminalProcessInspector/posix.ts create mode 100644 apps/server/src/terminalProcessInspector/types.ts create mode 100644 apps/server/src/terminalProcessInspector/utils.ts create mode 100644 apps/server/src/terminalProcessInspector/win32.ts create mode 100644 apps/server/src/webPortInspector.ts diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index f4ee6b904a2..cba76392e78 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -1,6 +1,5 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; -import { request as httpRequest } from "node:http"; import path from "node:path"; import { @@ -22,26 +21,22 @@ import { import { createLogger } from "./logger"; import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; -import { runProcess } from "./processRunner"; +import { + arePortListsEqual, + defaultSubprocessInspector, + normalizeRunningPorts, + subprocessCheckerToInspector, + type TerminalSubprocessActivity, + type TerminalSubprocessChecker, + type TerminalSubprocessInspector, + type TerminalWebPortInspector, +} from "./terminalProcessInspector"; +import { DEFAULT_WEB_PORT_PROBE_TTL_MS, defaultWebPortInspector } from "./webPortInspector"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); -const DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS = 500; -const DEFAULT_WEB_PORT_PROBE_TTL_MS = 10_000; -const WEB_PORT_PROBE_MAX_BODY_BYTES = 8_192; -const MAX_PORT_NUMBER = 65_535; - -type TerminalSubprocessChecker = (terminalPid: number) => Promise; -type TerminalWebPortInspector = (port: number) => Promise; -interface TerminalSubprocessActivity { - hasRunningSubprocess: boolean; - runningPorts: number[]; -} -type TerminalSubprocessInspector = ( - terminalPid: number, -) => Promise; export interface TerminalManagerEvents { event: [event: TerminalEvent]; @@ -142,362 +137,6 @@ function isRetryableShellSpawnError(error: unknown): boolean { ); } -function normalizeRunningPorts(ports: number[]): number[] { - if (ports.length === 0) return []; - return [...new Set(ports)] - .filter((port) => Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) - .toSorted((left, right) => left - right); -} - -function parsePidList(stdout: string): number[] { - const pids: number[] = []; - for (const line of stdout.split(/\r?\n/g)) { - const pid = Number(line.trim()); - if (!Number.isInteger(pid) || pid <= 0) { - continue; - } - pids.push(pid); - } - return [...new Set(pids)]; -} - -function parsePortList(stdout: string): number[] { - const ports: number[] = []; - for (const line of stdout.split(/\r?\n/g)) { - const port = Number(line.trim()); - if (!Number.isInteger(port)) { - continue; - } - ports.push(port); - } - return normalizeRunningPorts(ports); -} - -function portFromAddress(address: string): number | null { - const match = address.match(/:(\d+)$/); - if (!match?.[1]) return null; - const port = Number(match[1]); - if (!Number.isInteger(port) || port <= 0 || port > MAX_PORT_NUMBER) { - return null; - } - return port; -} - -function arePortListsEqual(left: number[], right: number[]): boolean { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) { - return false; - } - } - return true; -} - -async function collectWindowsChildPids(terminalPid: number): Promise { - const command = [ - `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, - "if (-not $children) { exit 0 }", - "$children | Select-Object -ExpandProperty ProcessId", - ].join("; "); - try { - const result = await runProcess( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", command], - { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 32_768, - outputMode: "truncate", - }, - ); - if (result.code !== 0) { - return []; - } - return parsePidList(result.stdout); - } catch { - return []; - } -} - -async function checkWindowsListeningPorts(processIds: number[]): Promise { - if (processIds.length === 0) return []; - const processFilter = processIds - .map((pid) => `$_.OwningProcess -eq ${pid}`) - .join(" -or "); - const command = [ - "$connections = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue", - `$matching = $connections | Where-Object { ${processFilter} }`, - "if (-not $matching) { exit 0 }", - "$matching | Select-Object -ExpandProperty LocalPort -Unique", - ].join("; "); - try { - const result = await runProcess( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", command], - { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 65_536, - outputMode: "truncate", - }, - ); - if (result.code !== 0) { - return []; - } - return parsePortList(result.stdout); - } catch { - return []; - } -} - -async function collectPosixProcessFamilyPids(terminalPid: number): Promise { - try { - const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { - timeoutMs: 1_000, - allowNonZeroExit: true, - maxBufferBytes: 262_144, - outputMode: "truncate", - }); - if (psResult.code !== 0) { - return []; - } - - const childrenByParentPid = new Map(); - for (const line of psResult.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - const children = childrenByParentPid.get(ppid); - if (children) { - children.push(pid); - } else { - childrenByParentPid.set(ppid, [pid]); - } - } - - const processFamily = new Set([terminalPid]); - const pendingParents = [terminalPid]; - while (pendingParents.length > 0) { - const parentPid = pendingParents.shift(); - if (!parentPid) continue; - const childPids = childrenByParentPid.get(parentPid); - if (!childPids || childPids.length === 0) continue; - for (const childPid of childPids) { - if (processFamily.has(childPid)) continue; - processFamily.add(childPid); - pendingParents.push(childPid); - } - } - - return [...processFamily]; - } catch { - return []; - } -} - -async function checkPosixListeningPorts(processIds: number[]): Promise { - if (processIds.length === 0) return []; - - const ports = new Set(); - const pidFilter = new Set(processIds); - - try { - const result = await runProcess( - "lsof", - ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")], - { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 262_144, - outputMode: "truncate", - }, - ); - if (result.code === 0) { - for (const line of result.stdout.split(/\r?\n/g)) { - const match = line.match(/:(\d+)\s+\(LISTEN\)$/); - if (!match?.[1]) continue; - const port = Number(match[1]); - if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) { - ports.add(port); - } - } - return [...ports].toSorted((left, right) => left - right); - } - } catch { - // Fall back to ss if lsof is unavailable. - } - - try { - const result = await runProcess("ss", ["-ltnp"], { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 524_288, - outputMode: "truncate", - }); - if (result.code !== 0) { - return []; - } - - for (const line of result.stdout.split(/\r?\n/g)) { - if (!line.includes("pid=")) continue; - const localAddress = line.trim().split(/\s+/g)[3]; - if (!localAddress) continue; - const port = portFromAddress(localAddress); - if (port === null) continue; - - const pidMatches = [...line.matchAll(/pid=(\d+)/g)]; - if (pidMatches.length === 0) continue; - if ( - pidMatches.some((match) => { - const pid = Number(match[1]); - return Number.isInteger(pid) && pidFilter.has(pid); - }) - ) { - ports.add(port); - } - } - return [...ports].toSorted((left, right) => left - right); - } catch { - return []; - } -} - -async function defaultSubprocessInspector( - terminalPid: number, -): Promise { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, runningPorts: [] }; - } - - if (process.platform === "win32") { - const childPids = await collectWindowsChildPids(terminalPid); - if (childPids.length === 0) { - return { hasRunningSubprocess: false, runningPorts: [] }; - } - const runningPorts = await checkWindowsListeningPorts(childPids); - return { hasRunningSubprocess: true, runningPorts }; - } - - const processFamilyPids = await collectPosixProcessFamilyPids(terminalPid); - const subprocessPids = processFamilyPids.filter((pid) => pid !== terminalPid); - if (subprocessPids.length === 0) { - return { hasRunningSubprocess: false, runningPorts: [] }; - } - - const runningPorts = await checkPosixListeningPorts(subprocessPids); - return { hasRunningSubprocess: true, runningPorts }; -} - -interface WebProbeResult { - status: number; - contentType: string; - body: string; - location: string; -} - -function normalizeHeaderValue(value: string | string[] | undefined): string { - if (typeof value === "string") return value; - if (Array.isArray(value)) return value[0] ?? ""; - return ""; -} - -function isLikelyWebProbe(result: WebProbeResult | null): boolean { - if (!result) return false; - if (result.status === 404) return false; - if (result.status >= 300 && result.status < 400 && result.location.length > 0) { - return true; - } - const contentType = result.contentType.toLowerCase(); - if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) { - return true; - } - const body = result.body.toLowerCase(); - return ( - body.includes(" { - return new Promise((resolve) => { - let timer: ReturnType | null = null; - let settled = false; - const settle = (result: WebProbeResult | null) => { - if (settled) return; - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - const req = httpRequest( - { - host, - port, - method: "GET", - path: "/", - timeout: DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS, - }, - (res) => { - const chunks: string[] = []; - let received = 0; - res.setEncoding("utf8"); - res.on("data", (chunk: string) => { - if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) return; - const remaining = WEB_PORT_PROBE_MAX_BODY_BYTES - received; - const fragment = chunk.slice(0, remaining); - received += fragment.length; - chunks.push(fragment); - if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) { - res.destroy(); - } - }); - res.on("end", () => { - settle({ - status: res.statusCode ?? 0, - contentType: normalizeHeaderValue(res.headers["content-type"]), - location: normalizeHeaderValue(res.headers.location), - body: chunks.join(""), - }); - }); - res.on("error", () => { - settle(null); - }); - }, - ); - - req.on("timeout", () => { - req.destroy(); - settle(null); - }); - req.on("error", () => { - settle(null); - }); - - timer = setTimeout(() => { - req.destroy(); - settle(null); - }, DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS + 50); - - req.end(); - }); -} - -async function defaultWebPortInspector(port: number): Promise { - const ipv4Result = await probeWebPortOnHost(port, "127.0.0.1"); - if (isLikelyWebProbe(ipv4Result)) { - return true; - } - const ipv6Result = await probeWebPortOnHost(port, "::1"); - return isLikelyWebProbe(ipv6Result); -} - function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); @@ -581,10 +220,7 @@ export class TerminalManager extends EventEmitter { this.subprocessInspector = options.subprocessInspector ?? (options.subprocessChecker - ? async (terminalPid: number) => ({ - hasRunningSubprocess: await options.subprocessChecker!(terminalPid), - runningPorts: [], - }) + ? subprocessCheckerToInspector(options.subprocessChecker) : defaultSubprocessInspector); this.webPortInspector = options.webPortInspector ?? defaultWebPortInspector; this.webPortProbeCacheTtlMs = diff --git a/apps/server/src/terminalProcessInspector/index.ts b/apps/server/src/terminalProcessInspector/index.ts new file mode 100644 index 00000000000..dd8636de6e3 --- /dev/null +++ b/apps/server/src/terminalProcessInspector/index.ts @@ -0,0 +1,55 @@ +import { collectPosixProcessFamilyPids, checkPosixListeningPorts } from "./posix"; +import { + type TerminalSubprocessActivity, + type TerminalSubprocessChecker, + type TerminalSubprocessInspector, + type TerminalWebPortInspector, +} from "./types"; +import { checkWindowsListeningPorts, collectWindowsChildPids } from "./win32"; + +export { + arePortListsEqual, + normalizeRunningPorts, +} from "./utils"; + +export type { + TerminalSubprocessActivity, + TerminalSubprocessChecker, + TerminalSubprocessInspector, + TerminalWebPortInspector, +} from "./types"; + +export async function defaultSubprocessInspector( + terminalPid: number, +): Promise { + if (!Number.isInteger(terminalPid) || terminalPid <= 0) { + return { hasRunningSubprocess: false, runningPorts: [] }; + } + + if (process.platform === "win32") { + const childPids = await collectWindowsChildPids(terminalPid); + if (childPids.length === 0) { + return { hasRunningSubprocess: false, runningPorts: [] }; + } + const runningPorts = await checkWindowsListeningPorts(childPids); + return { hasRunningSubprocess: true, runningPorts }; + } + + const processFamilyPids = await collectPosixProcessFamilyPids(terminalPid); + const subprocessPids = processFamilyPids.filter((pid) => pid !== terminalPid); + if (subprocessPids.length === 0) { + return { hasRunningSubprocess: false, runningPorts: [] }; + } + + const runningPorts = await checkPosixListeningPorts(subprocessPids); + return { hasRunningSubprocess: true, runningPorts }; +} + +export function subprocessCheckerToInspector( + subprocessChecker: TerminalSubprocessChecker, +): TerminalSubprocessInspector { + return async (terminalPid: number) => ({ + hasRunningSubprocess: await subprocessChecker(terminalPid), + runningPorts: [], + }); +} diff --git a/apps/server/src/terminalProcessInspector/posix.ts b/apps/server/src/terminalProcessInspector/posix.ts new file mode 100644 index 00000000000..06280190e6e --- /dev/null +++ b/apps/server/src/terminalProcessInspector/posix.ts @@ -0,0 +1,119 @@ +import { runProcess } from "../processRunner"; +import { MAX_PORT_NUMBER, portFromAddress } from "./utils"; + +export async function collectPosixProcessFamilyPids(terminalPid: number): Promise { + try { + const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 262_144, + outputMode: "truncate", + }); + if (psResult.code !== 0) { + return []; + } + + const childrenByParentPid = new Map(); + for (const line of psResult.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + const children = childrenByParentPid.get(ppid); + if (children) { + children.push(pid); + } else { + childrenByParentPid.set(ppid, [pid]); + } + } + + const processFamily = new Set([terminalPid]); + const pendingParents = [terminalPid]; + while (pendingParents.length > 0) { + const parentPid = pendingParents.shift(); + if (!parentPid) continue; + const childPids = childrenByParentPid.get(parentPid); + if (!childPids || childPids.length === 0) continue; + for (const childPid of childPids) { + if (processFamily.has(childPid)) continue; + processFamily.add(childPid); + pendingParents.push(childPid); + } + } + + return [...processFamily]; + } catch { + return []; + } +} + +export async function checkPosixListeningPorts(processIds: number[]): Promise { + if (processIds.length === 0) return []; + + const ports = new Set(); + const pidFilter = new Set(processIds); + + try { + const result = await runProcess( + "lsof", + ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 262_144, + outputMode: "truncate", + }, + ); + if (result.code !== 0) { + // `lsof` returns 1 when there are no matching files/sockets. + // This is a valid "no results" outcome; avoid falling back to `ss`. + return []; + } + + for (const line of result.stdout.split(/\r?\n/g)) { + const match = line.match(/:(\d+)\s+\(LISTEN\)$/); + if (!match?.[1]) continue; + const port = Number(match[1]); + if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) { + ports.add(port); + } + } + return [...ports].toSorted((left, right) => left - right); + } catch { + // Fall back to ss if lsof is unavailable. + } + + try { + const result = await runProcess("ss", ["-ltnp"], { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 524_288, + outputMode: "truncate", + }); + if (result.code !== 0) { + return []; + } + + for (const line of result.stdout.split(/\r?\n/g)) { + if (!line.includes("pid=")) continue; + const localAddress = line.trim().split(/\s+/g)[3]; + if (!localAddress) continue; + const port = portFromAddress(localAddress); + if (port === null) continue; + + const pidMatches = [...line.matchAll(/pid=(\d+)/g)]; + if (pidMatches.length === 0) continue; + if ( + pidMatches.some((match) => { + const pid = Number(match[1]); + return Number.isInteger(pid) && pidFilter.has(pid); + }) + ) { + ports.add(port); + } + } + return [...ports].toSorted((left, right) => left - right); + } catch { + return []; + } +} diff --git a/apps/server/src/terminalProcessInspector/types.ts b/apps/server/src/terminalProcessInspector/types.ts new file mode 100644 index 00000000000..4d80fd38a9b --- /dev/null +++ b/apps/server/src/terminalProcessInspector/types.ts @@ -0,0 +1,12 @@ +export type TerminalSubprocessChecker = (terminalPid: number) => Promise; + +export type TerminalWebPortInspector = (port: number) => Promise; + +export interface TerminalSubprocessActivity { + hasRunningSubprocess: boolean; + runningPorts: number[]; +} + +export type TerminalSubprocessInspector = ( + terminalPid: number, +) => Promise; diff --git a/apps/server/src/terminalProcessInspector/utils.ts b/apps/server/src/terminalProcessInspector/utils.ts new file mode 100644 index 00000000000..d59b6b3eaa8 --- /dev/null +++ b/apps/server/src/terminalProcessInspector/utils.ts @@ -0,0 +1,52 @@ +export const MAX_PORT_NUMBER = 65_535; + +export function normalizeRunningPorts(ports: number[]): number[] { + if (ports.length === 0) return []; + return [...new Set(ports)] + .filter((port) => Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) + .toSorted((left, right) => left - right); +} + +export function parsePidList(stdout: string): number[] { + const pids: number[] = []; + for (const line of stdout.split(/\r?\n/g)) { + const pid = Number(line.trim()); + if (!Number.isInteger(pid) || pid <= 0) { + continue; + } + pids.push(pid); + } + return [...new Set(pids)]; +} + +export function parsePortList(stdout: string): number[] { + const ports: number[] = []; + for (const line of stdout.split(/\r?\n/g)) { + const port = Number(line.trim()); + if (!Number.isInteger(port)) { + continue; + } + ports.push(port); + } + return normalizeRunningPorts(ports); +} + +export function portFromAddress(address: string): number | null { + const match = address.match(/:(\d+)$/); + if (!match?.[1]) return null; + const port = Number(match[1]); + if (!Number.isInteger(port) || port <= 0 || port > MAX_PORT_NUMBER) { + return null; + } + return port; +} + +export function arePortListsEqual(left: number[], right: number[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} diff --git a/apps/server/src/terminalProcessInspector/win32.ts b/apps/server/src/terminalProcessInspector/win32.ts new file mode 100644 index 00000000000..0469b392b02 --- /dev/null +++ b/apps/server/src/terminalProcessInspector/win32.ts @@ -0,0 +1,60 @@ +import { runProcess } from "../processRunner"; +import { parsePidList, parsePortList } from "./utils"; + +export async function collectWindowsChildPids(terminalPid: number): Promise { + const command = [ + `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, + "if (-not $children) { exit 0 }", + "$children | Select-Object -ExpandProperty ProcessId", + ].join("; "); + try { + const result = await runProcess( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", command], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }, + ); + if (result.code !== 0) { + return []; + } + return parsePidList(result.stdout); + } catch { + return []; + } +} + +export async function checkWindowsListeningPorts(processIds: number[]): Promise { + if (processIds.length === 0) return []; + + const processFilter = processIds + .map((pid) => `$_.OwningProcess -eq ${pid}`) + .join(" -or "); + const command = [ + "$connections = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue", + `$matching = $connections | Where-Object { ${processFilter} }`, + "if (-not $matching) { exit 0 }", + "$matching | Select-Object -ExpandProperty LocalPort -Unique", + ].join("; "); + try { + const result = await runProcess( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", command], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 65_536, + outputMode: "truncate", + }, + ); + if (result.code !== 0) { + return []; + } + return parsePortList(result.stdout); + } catch { + return []; + } +} diff --git a/apps/server/src/webPortInspector.ts b/apps/server/src/webPortInspector.ts new file mode 100644 index 00000000000..2ddec0593b8 --- /dev/null +++ b/apps/server/src/webPortInspector.ts @@ -0,0 +1,114 @@ +import { request as httpRequest } from "node:http"; + +export const DEFAULT_WEB_PORT_PROBE_TTL_MS = 10_000; +const DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS = 500; +const WEB_PORT_PROBE_MAX_BODY_BYTES = 8_192; + +interface WebProbeResult { + status: number; + contentType: string; + body: string; + location: string; +} + +function normalizeHeaderValue(value: string | string[] | undefined): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value[0] ?? ""; + return ""; +} + +function isLikelyWebProbe(result: WebProbeResult | null): boolean { + if (!result) return false; + if (result.status === 404) return false; + if (result.status >= 300 && result.status < 400 && result.location.length > 0) { + return true; + } + const contentType = result.contentType.toLowerCase(); + if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) { + return true; + } + const body = result.body.toLowerCase(); + return ( + body.includes(" { + return new Promise((resolve) => { + let timer: ReturnType | null = null; + let settled = false; + const settle = (result: WebProbeResult | null) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + } + resolve(result); + }; + + const req = httpRequest( + { + host, + port, + method: "GET", + path: "/", + timeout: DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS, + }, + (res) => { + const chunks: string[] = []; + let received = 0; + res.setEncoding("utf8"); + res.on("data", (chunk: string) => { + if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) return; + const remaining = WEB_PORT_PROBE_MAX_BODY_BYTES - received; + const fragment = chunk.slice(0, remaining); + received += fragment.length; + chunks.push(fragment); + if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) { + res.destroy(); + } + }); + res.on("end", () => { + settle({ + status: res.statusCode ?? 0, + contentType: normalizeHeaderValue(res.headers["content-type"]), + location: normalizeHeaderValue(res.headers.location), + body: chunks.join(""), + }); + }); + res.on("error", () => { + settle(null); + }); + }, + ); + + req.on("timeout", () => { + req.destroy(); + settle(null); + }); + req.on("error", () => { + settle(null); + }); + + timer = setTimeout(() => { + req.destroy(); + settle(null); + }, DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS + 50); + + req.end(); + }); +} + +export async function defaultWebPortInspector(port: number): Promise { + const ipv4Result = await probeWebPortOnHost(port, "127.0.0.1"); + if (isLikelyWebProbe(ipv4Result)) { + return true; + } + const ipv6Result = await probeWebPortOnHost(port, "::1"); + return isLikelyWebProbe(ipv6Result); +} From c11027be4d9183c9028f91bf68c0abb5934b103b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 18:25:22 -0800 Subject: [PATCH 05/13] Show terminal runtime status and web port actions in thread UI - Pass running terminal IDs/ports from `ChatView` into `ThreadTerminalDrawer` - Add runtime badges/icons for running terminals and localhost web ports - Make sidebar thread rows keyboard-activatable and add web-port quick open button --- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/Sidebar.tsx | 94 ++++++------- .../src/components/ThreadTerminalDrawer.tsx | 126 +++++++++++++++--- 3 files changed, 145 insertions(+), 77 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5114a24468f..b9e38527c25 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1448,6 +1448,8 @@ export default function ChatView() { activeTerminalId={activeThread.activeTerminalId} terminalGroups={activeThread.terminalGroups} activeTerminalGroupId={activeThread.activeTerminalGroupId} + runningTerminalIds={activeThread.runningTerminalIds} + runningTerminalPorts={activeThread.runningTerminalPorts} focusRequestId={terminalFocusRequestId} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aed7909a84c..2cbe2266390 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { GlobeIcon, MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; +import { GlobeIcon, MonitorIcon, MoonIcon, SunIcon, TerminalIcon, TerminalSquareIcon } from "lucide-react"; import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -47,12 +47,7 @@ interface ThreadStatusPill { pulse: boolean; } -interface TerminalStatusIndicator { - label: string; - colorClass: string; - pulse: boolean; - primaryWebPort: number | null; -} + function hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurnCompletedAt) return false; @@ -105,7 +100,7 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS return null; } -function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { +function terminalStatusIndicator(thread: Thread){ if (thread.runningTerminalIds.length === 0) { return null; } @@ -127,11 +122,6 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null return { label, - colorClass: - runningPorts.length >= 1 - ? "text-sky-600 dark:text-sky-300/90" - : "text-teal-600 dark:text-teal-300/90", - pulse: true, primaryWebPort: runningPorts[0] ?? null, }; } @@ -519,9 +509,10 @@ export default function Sidebar() { ); const terminalStatus = terminalStatusIndicator(thread); return ( -
{terminalStatus && ( - - {terminalStatus.primaryWebPort !== null ? ( - - openWebPort(event, terminalStatus.primaryWebPort!) - } - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - if (!api) return; - void api.shell - .openExternal( - `http://localhost:${terminalStatus.primaryWebPort!}`, - ) - .catch(() => undefined); - }} - > - - - ) : ( - - - - )} - + terminalStatus.primaryWebPort !== null ? ( + + ) : ( + + + + ) )} {formatRelativeTime(thread.createdAt)}
- + ); })} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9e544ab94d..41cb7c5e163 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,10 +1,8 @@ import { FitAddon } from "@xterm/addon-fit"; -import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; +import { Globe, Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; import { type NativeApi } from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { - type PointerEvent as ReactPointerEvent, - type ReactNode, useCallback, useEffect, useMemo, @@ -43,6 +41,45 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } +interface TerminalRuntimeStatus { + label: string; + primaryWebPort: number | null; + extraWebPortCount: number; +} + +function normalizeRunningPorts(rawPorts: number[] | undefined): number[] { + if (!rawPorts) return []; + return [...new Set(rawPorts)] + .filter((port) => Number.isInteger(port) && port > 0 && port <= 65_535) + .toSorted((left, right) => left - right); +} + +function terminalRuntimeStatus( + terminalId: string, + runningTerminalIds: Set, + runningTerminalPorts: Record, +): TerminalRuntimeStatus | null { + if (!runningTerminalIds.has(terminalId)) { + return null; + } + + const runningPorts = normalizeRunningPorts(runningTerminalPorts[terminalId]); + const primaryWebPort = runningPorts[0] ?? null; + const extraWebPortCount = runningPorts.length > 1 ? runningPorts.length - 1 : 0; + const label = + runningPorts.length === 0 + ? "Terminal process running" + : runningPorts.length === 1 + ? `Open web server: http://localhost:${primaryWebPort}` + : `Open web server: http://localhost:${primaryWebPort} (detected web ports: ${runningPorts.join(", ")})`; + + return { + label, + primaryWebPort, + extraWebPortCount, + }; +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -413,6 +450,8 @@ interface ThreadTerminalDrawerProps { activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; + runningTerminalIds: string[]; + runningTerminalPorts: Record; focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; @@ -427,7 +466,7 @@ interface TerminalActionButtonProps { label: string; className: string; onClick: () => void; - children: ReactNode; + children: React.ReactNode; } function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { @@ -467,6 +506,8 @@ export default function ThreadTerminalDrawer({ activeTerminalId, terminalGroups, activeTerminalGroupId, + runningTerminalIds, + runningTerminalPorts, focusRequestId, onSplitTerminal, onNewTerminal, @@ -587,6 +628,10 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); + const runningTerminalIdSet = useMemo( + () => new Set(runningTerminalIds.map((terminalId) => terminalId.trim())), + [runningTerminalIds], + ); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -610,7 +655,7 @@ export default function ThreadTerminalDrawer({ lastSyncedHeightRef.current = clampedHeight; }, [height, threadId]); - const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { + const handleResizePointerDown = useCallback((event: React.PointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -622,7 +667,7 @@ export default function ThreadTerminalDrawer({ }; }, []); - const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { + const handleResizePointerMove = useCallback((event: React.PointerEvent) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; event.preventDefault(); @@ -638,7 +683,7 @@ export default function ThreadTerminalDrawer({ }, []); const handleResizePointerEnd = useCallback( - (event: ReactPointerEvent) => { + (event: React.PointerEvent) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; resizeStateRef.current = null; @@ -679,6 +724,15 @@ export default function ThreadTerminalDrawer({ }; }, [syncHeight]); + const openWebPort = useCallback( + (event: React.MouseEvent, port: number) => { + event.preventDefault(); + event.stopPropagation(); + void api.shell.openExternal(`http://localhost:${port}`).catch(() => undefined); + }, + [api], + ); + return (