diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 903e9a7010094..3dee90313a2b3 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -62,6 +62,17 @@ const layerProbeFailure = ( const LSOF_TEST_PORT = 43_123; +const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + const ownedScanner = (processIds: ReadonlyArray = [1234]) => Effect.tap(PortScanner.PortDiscovery, (scanner) => scanner.registerTerminalProcesses({ @@ -174,10 +185,11 @@ effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites" effectIt.effect("projects configured paths independently for simultaneous subscribers", () => { const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + let ready = true; const fetchFn = ((input: Parameters[0]) => { const url = String(input); return Promise.resolve( - url === docsUrl || url === adminUrl + ready && (url === docsUrl || url === adminUrl) ? new Response("app", { headers: { "content-type": "text/html" } }) : new Response("not found", { status: 404 }), ); @@ -185,7 +197,7 @@ effectIt.effect("projects configured paths independently for simultaneous subscr const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const docsSnapshots: ReadonlyArray[] = []; const adminSnapshots: ReadonlyArray[] = []; yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) => @@ -198,6 +210,11 @@ effectIt.effect("projects configured paths independently for simultaneous subscr expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); + + ready = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); + expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -289,28 +306,45 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { - const redirects: Array = []; - const fetchFn = (async ( - input: Parameters[0], - init?: Parameters[1], - ) => { - redirects.push(init?.redirect); - if (String(input).startsWith("http:")) throw new TypeError("TLS listener"); - return new Response(null, { status: 302, headers: { location: "https://example.com" } }); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); +effectIt.effect( + "probes only a configured HTTPS URL, keeps it for an owned listener when the check fails, and does not follow redirects", + () => { + const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; + const requests: Array = []; + let respond = async () => + new Response(null, { status: 302, headers: { location: "https://example.com" } }); + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push([String(input), init?.redirect]); + return respond(); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - const servers = yield* scanner.scan(); - expect(servers).toHaveLength(1); - expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); - expect(redirects).toEqual(["manual", "manual"]); - }).pipe(Effect.provide(layer)); -}); + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + + respond = async () => new Response("unavailable", { status: 503 }); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); -effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + respond = () => Promise.reject(new TypeError("fetch failed")); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + expect(requests).toEqual(Array.from({ length: 3 }, () => [configuredUrl, "manual"])); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => { const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; let stdout = unowned; @@ -330,19 +364,19 @@ effectIt.effect("probes a port only while T3 terminals own every listener on it" stdout = owned; expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + expect(requests).toEqual([]); yield* scanner.unregisterTerminal({ threadId: "scanner-thread", terminalId: "scanner-terminal", }); expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toHaveLength(1); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); effectIt.effect( - "excludes HTTP errors, non-navigation responses, and successful non-documents", + "excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents", () => { let pid = 1; let makeResponse = () => @@ -350,33 +384,35 @@ effectIt.effect( const fetchFn = ((_input: Parameters[0]) => Promise.resolve(makeResponse())) as typeof globalThis.fetch; const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/`; return Effect.gen(function* () { - const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); - expect(yield* scanner.scan()).toHaveLength(0); + const scanner = yield* PortScanner.PortDiscovery; + const scan = () => scanner.scan([configuredUrl]); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 204, headers: { "content-type": "text/html" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 302 }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => @@ -384,7 +420,7 @@ effectIt.effect( status: 200, headers: { "content-type": "application/xhtml+xml; charset=utf-8" }, }); - expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scan()).toHaveLength(1); }).pipe(Effect.provide(layer)); }, ); @@ -433,10 +469,11 @@ effectIt.effect( return target === undefined ? missing.readLink(path) : Effect.succeed(target); }, }); - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = layerProbeFailure( (input) => { lsofSpawns += 1; @@ -449,37 +486,45 @@ effectIt.effect( return Effect.gen(function* () { const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); - expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 8765, pid: 4242, processName: "python3" }, - ]); + expect( + first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })), + ).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]); yield* scanner.scan(); expect(lsofSpawns).toBe(1); expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); yield* scanner.scan(); yield* scanner.scan(); expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }, ); effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + let ipv6Fails = true; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => { + if (input.command === "tasklist.exe") { + return Effect.succeed( + processOutput('"node.exe","1234","Console","1","50,000 K"\r\n'), + ); + } const isIpv6 = input.args.includes("tcpv6"); return Effect.succeed({ stdout: isIpv6 ? "" : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n", stderr: "", - code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null, + code: isIpv6 && ipv6Fails ? ChildProcessSpawner.ExitCode(1) : null, timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -502,20 +547,14 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits processIds: [1234], }); expect(yield* scanner.scan()).toEqual([]); + ipv6Fails = false; + expect(yield* scanner.scan()).toMatchObject([ + { port: 5173, pid: 1234, processName: "node", url: "http://localhost:5173" }, + ]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); -const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ - stdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, -}); - effectIt.effect("rechecks the socket owner before stopping a Linux server without lsof", () => { let holder = "4242"; const missing = FileSystem.makeNoop({}); @@ -569,6 +608,63 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou }).pipe(Effect.provide(layer)); }); +effectIt.effect.each(["linux", "darwin", "win32"] as const)( + "reads no listeners on %s while T3CODE_PORT_DISCOVERY=0, and still probes configured URLs", + (platform) => { + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const commands: string[] = []; + const fileReads: string[] = []; + const requests: string[] = []; + const missing = FileSystem.makeNoop({}); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + fileReads.push(path); + return missing.readFileString(path); + }, + readDirectory: (path) => { + fileReads.push(path); + return missing.readDirectory(path); + }, + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + commands.push(input.command); + return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`)); + }, + }), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Environment, { T3CODE_PORT_DISCOVERY: "0" }), + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch, (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response("docs", { headers: { "content-type": "text/html" } }), + ); + }), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + scanner.trackAgent(); + expect(yield* scanner.scan()).toEqual([]); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { port: LSOF_TEST_PORT, pid: null, terminal: null, url: configuredUrl }, + ]); + expect(requests).toEqual([configuredUrl]); + expect(commands).toEqual([]); + expect(fileReads).toEqual([]); + }).pipe(Effect.provide(layer)); + }, +); + effectIt.effect.each(["linux", "darwin"] as const)( "attributes listeners to the agent thread in their environment on %s, after the agent exits", (platform) => { @@ -672,7 +768,7 @@ effectIt.effect.each(["linux", "darwin"] as const)( expect(yield* scanner.scan()).toMatchObject([ { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, ]); - expect(requests).toEqual([`http://localhost:${agentPort}/`]); + expect(requests).toEqual([]); expect(yield* scanner.scan()).toHaveLength(1); expect(environmentReads).toBe(2); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 1080624d7508d..e026b204c03d8 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -68,6 +68,9 @@ export class PortDiscovery extends Context.Service< */ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; +/** `0` turns off listener discovery; configured preview URLs are still probed. */ +const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; + const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; @@ -132,8 +135,8 @@ interface WebProbeCacheEntry { interface WebProbeGroup { readonly server: DiscoveredLocalServer; - readonly urls: ReadonlyArray; - readonly configuredKey: string | null; + readonly url: string; + readonly configuredKey: string; } interface WebProbeSnapshot { @@ -189,17 +192,22 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const firstConfiguredByServer = new Map(); for (const raw of normalizeConfiguredUrls(configuredUrls)) { const url = new URL(raw); const port = urlPort(url); const serverKey = localServerKey(url.hostname, port); + if (!firstConfiguredByServer.has(serverKey)) firstConfiguredByServer.set(serverKey, raw); if (visibleByServer.has(serverKey)) continue; const configured = snapshot.configured.get(webProbeCacheKey(raw)); if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); } + // An owned listener whose configured check failed still opens at this caller's configured URL. for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); - if (!visibleByServer.has(key)) visibleByServer.set(key, server); + if (visibleByServer.has(key)) continue; + const configuredUrl = firstConfiguredByServer.get(key); + visibleByServer.set(key, configuredUrl ? { ...server, url: configuredUrl } : server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); }; @@ -498,6 +506,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; + const listenerDiscovery = (yield* HostProcess.Environment)[PORT_DISCOVERY_ENV] !== "0"; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ listeners: new Map(), @@ -715,24 +724,17 @@ export const make = Effect.gen(function* PortDiscoveryMake() { pid: null, terminal: null, }, - urls: [raw], + url: raw, configuredKey: resourceKey, }); } - for (const server of servers) { - // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. - if (!isOwned(server)) continue; - groups.push({ - server, - urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], - configuredKey: null, - }); - } - return groups; }; + // Only configured URLs are requested. Owned listeners are listed as plain HTTP without a + // request: owning a process says nothing about its protocol, and HTTP or TLS bytes break + // binary ones such as Unity's test runner or Steam's IPC. const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray, configuredUrls: ReadonlyArray, @@ -740,77 +742,37 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); - const batchProbes = new Map< - string, - Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> - >(); - const batchProbeSemaphore = yield* Semaphore.make(1); - const getProbe = (url: string, pid: number | null) => { - const key = webProbeCacheKey(url); - const identity = `${key}\u0000${pid ?? ""}`; - return batchProbeSemaphore - .withPermits(1)( - Effect.gen(function* () { - const existing = batchProbes.get(identity); - if (existing) return [existing] as const; - const cachedProbe = cached.get(key); - const cachedIsCurrent = - cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; - const memoized = yield* Effect.cached( - cachedIsCurrent - ? Effect.succeed({ probe: cachedProbe, fresh: false }) - : probeWebUrl(url).pipe( - Effect.map((result) => ({ - probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, - fresh: true, - })), - ), - ); - batchProbes.set(identity, memoized); - return [memoized] as const; - }), - ) - .pipe(Effect.flatMap(([probe]) => probe)); - }; const probed = yield* Effect.forEach( groups, - (group) => - Effect.gen(function* () { - const probes: Array = []; - let visibleUrl: string | null = null; - for (const url of group.urls) { - const key = webProbeCacheKey(url); - const { probe, fresh } = yield* getProbe(url, group.server.pid); - probes.push([key, probe, fresh]); - if (probe.isWeb) { - visibleUrl = url; - break; - } - } - return { group, probes, visibleUrl }; - }), + (group) => { + const { pid } = group.server; + const cachedProbe = cached.get(group.configuredKey); + return cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis + ? Effect.succeed({ group, probe: cachedProbe, fresh: false }) + : probeWebUrl(group.url).pipe( + Effect.map((result) => ({ + group, + probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, + fresh: true, + })), + ); + }, { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); - const discovered: DiscoveredLocalServer[] = []; const configured = new Map(); - for (const { group, probes, visibleUrl } of probed) { - for (const [key, probe, fresh] of probes) { - nextCache.set( - key, - fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, - ); - } - if (visibleUrl === null) continue; - const server = { ...group.server, url: visibleUrl }; - if (group.configuredKey === null) discovered.push(server); - else configured.set(group.configuredKey, server); + for (const { group, probe, fresh } of probed) { + nextCache.set( + group.configuredKey, + fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, + ); + if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url }); } yield* Ref.set(webProbeCacheRef, nextCache); - return { discovered, configured } satisfies WebProbeSnapshot; + return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -873,6 +835,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray, ) { + if (!listenerDiscovery) return yield* probeWebServers([], configuredUrls); const state = yield* Ref.get(stateRef); const terminalOwners = new Map(); for (const registration of state.terminalProcesses.values()) { diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx index 353f6032b6f87..f1c8e1a1808f1 100644 --- a/apps/web/src/components/chat/ThreadServersPanel.tsx +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -30,7 +30,7 @@ import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./threadDetailsPanelStyles"; const serverKey = (server: DiscoveredLocalServer) => `${server.pid}:${server.port}`; -/** Web servers the thread's terminals and agent started. Hidden while none run. */ +/** Local servers the thread's terminals and agent started. Hidden while none run. */ export function ThreadServersPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 615d7e22586fb..fc70ff9ecb060 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -40,7 +40,7 @@ export function PreviewEmptyState({ No preview yet - Type a URL above, or run a dev script. Browser-ready localhost servers will show up here + Type a URL above, or run a dev script. Local servers this thread starts will show up here automatically. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index e78992b69f200..9c426b64b1344 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -243,6 +243,19 @@ On a phone, tap the floating preview's corner dot to show its controls, then **Pop into separate window** to keep watching in picture-in-picture over other apps. +### Local servers + +The browser lists ports held by processes your terminals and agents started. +T3 Code reads which process holds each port and never connects to it, so a +listed port opens as `http://localhost:` and might not be a web page. For +an HTTPS server or a specific path, set the **Preview URL** on the project +script that starts it; T3 Code checks that address and opens it as written. + +To stop listing ports, set `T3CODE_PORT_DISCOVERY=0` in the environment that +starts the T3 Code server, such as +[`service.env`](./background-service.md#configure-the-service-environment) for +the background service, and restart it. Script preview URLs still appear. + ### Browser host setup macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fb5e12b08372b..062fd749a1793 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -270,9 +270,10 @@ Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it finishes; the call's own result shows its status. -On web and desktop, the thread details card lists the web servers that the -thread's terminals or its Claude agent started, with their ports. A server the -agent started stays listed after the agent's turn ends. +On web and desktop, the thread details card lists the local servers that the +thread's terminals or its Claude agent started, with their ports. Not every one +serves a web page. A server the agent started stays listed after the agent's +turn ends. Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows.