From 67a29945f6c8c9c362e6d80ca39c1d8ec7e83a2c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 14:45:34 -0700 Subject: [PATCH 1/2] feat(server,web): see and stop the dev servers a thread started Claude CLIs start with T3CODE_THREAD_ID, so port discovery links the servers their jobs start to the thread. Thread details lists them, and Stop ends a server's own process group. Builds on #21692. Co-authored-by: Guillermo Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../Adapters/ClaudeAdapterV2.test.ts | 3 +- .../Adapters/ClaudeAdapterV2.ts | 15 +- .../ClaudeAgentSdkQueryRunner.test.ts | 53 +++ apps/server/src/preview/PortScanner.test.ts | 314 ++++++++++++++- apps/server/src/preview/PortScanner.ts | 371 ++++++++++++++++-- .../provider/ProviderInstanceRegistry.test.ts | 2 + .../ProviderInstanceRegistryHydration.ts | 5 +- .../src/provider/ProviderRegistry.test.ts | 15 +- apps/server/src/server.ts | 3 + apps/server/src/ws.test.ts | 2 +- apps/server/src/ws.ts | 1 + .../components/chat/ThreadDetailsPanel.tsx | 5 + .../components/chat/ThreadServersPanel.tsx | 125 ++++++ apps/web/src/portDiscoveryState.ts | 5 +- docs/user/thread-sidebar.md | 7 + packages/client-runtime/src/state/preview.ts | 4 + .../contracts/src/clientRpcPermissions.ts | 3 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/preview.ts | 33 +- packages/contracts/src/rpc.ts | 9 + 24 files changed, 938 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadServersPanel.tsx diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 581517de719d8..397adb04fbf7f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -59,6 +59,7 @@ describe("RPC authorization scopes", () => { WS_METHODS.terminalClear, WS_METHODS.terminalRestart, WS_METHODS.terminalClose, + WS_METHODS.previewStopLocalServer, ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope); } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 35e2bd4776fb3..f88bcef8ee34c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -196,6 +196,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewClearProfile]: AuthPreviewOperateScope, [WS_METHODS.previewReportProfiles]: AuthPreviewOperateScope, [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope, + [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index fe47ea862d03f..312556237f506 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -255,6 +255,7 @@ export const make = Effect.gen(function* () { threadPullRequestLinking: true, serverResolvedCommandContext: true, threadStop: true, + ...(hostPlatform === "win32" ? {} : { localServerStop: true }), environmentIcon: true, projectCloneTracking: true, ignoredFileListing: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index a4c6dc4120806..88a53889377d2 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -204,6 +204,7 @@ const RPC_AGGREGATES = { [WS_METHODS.previewReportStatus]: "preview", [WS_METHODS.subscribePreviewEvents]: "preview", [WS_METHODS.subscribeDiscoveredLocalServers]: "preview", + [WS_METHODS.previewStopLocalServer]: "preview", [WS_METHODS.deviceConfigure]: "device", [WS_METHODS.deviceTestHost]: "device", [WS_METHODS.deviceEasAccount]: "device", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 470e04a384ddd..81d525923da58 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -74,6 +74,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as PortScanner from "../../preview/PortScanner.ts"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { WorkflowAgentTranscriptReadObserver } from "../workflowAgentTranscript.ts"; import * as ServerConfig from "../../config.ts"; @@ -2271,7 +2272,7 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { }, }); }).pipe( - Effect.provide(ClaudeAdapterV2.layerQueryRunner), + Effect.provide(ClaudeAdapterV2.layerQueryRunner.pipe(Layer.provide(PortScanner.layerTest))), Effect.provideService(ProviderEventLoggers.ProviderEventLoggers, { ...ProviderEventLoggers.NoOpProviderEventLoggers, native, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5644051d9577d..83f43f39b8f60 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -138,6 +138,7 @@ import { type CompiledClaudeModelSelection, } from "../../claudeModelOptions.ts"; import * as ServerConfig from "../../config.ts"; +import * as PortScanner from "../../preview/PortScanner.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; @@ -902,6 +903,7 @@ export const layerQueryRunner: Layer.Layer< | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Path.Path + | PortScanner.PortDiscovery | ProviderEventLoggers.ProviderEventLoggers > = Layer.effect( ClaudeAgentSdkQueryRunner, @@ -913,6 +915,8 @@ export const layerQueryRunner: Layer.Layer< const agentScope = yield* AgentScope; const platform = yield* HostProcess.Platform; const runFork = Effect.runForkWith(yield* Effect.context()); + const hostEnvironment = yield* HostProcess.Environment; + const portDiscovery = yield* PortScanner.PortDiscovery; return ClaudeAgentSdkQueryRunner.of({ allocateSessionId: crypto @@ -936,7 +940,15 @@ export const layerQueryRunner: Layer.Layer< Stream.toAsyncIterable, ); // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. - const { mcpServers, ...queryOptions } = input.options; + const { mcpServers, ...sdkOptions } = input.options; + // Jobs the agent starts inherit the thread, so port discovery lists them after the CLI exits. + const queryOptions = { + ...sdkOptions, + env: { + ...(sdkOptions.env ?? hostEnvironment), + [PortScanner.AGENT_THREAD_ENV]: input.threadId, + }, + }; // The SDK can prepend a scope wrapper to native binaries; script entries run through node. const binaryPath = queryOptions.pathToClaudeCodeExecutable; const launch = @@ -983,6 +995,7 @@ export const layerQueryRunner: Layer.Layer< const spawnClaudeCodeProcess = (spawnOptions: ClaudeSpawnOptions) => { const child = spawnProcess(spawnOptions); spawnedProcess = child; + if (child.pid !== undefined) child.once("exit", portDiscovery.trackAgent()); processExited = child.exitCode !== null || child.signalCode != null; processExit = new Promise((resolve) => { if (processExited) resolve(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index d0333a1b05597..dac2095d0d223 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -24,6 +24,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; +import * as PortScanner from "../../preview/PortScanner.ts"; import { ClaudeAgentSdkQueryRunner, type ClaudeAgentSdkQueryOpenInput, @@ -66,6 +67,7 @@ const writeClaudeSession = Effect.fnUntraced(function* (configDir: string) { }); const forkRunnerLayer = layerQueryRunner.pipe( + Layer.provide(PortScanner.layerTest), Layer.provide(Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined })), ); @@ -110,6 +112,7 @@ it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", ( ).pipe(Effect.provide(NodeServices.layer)), ); const realQueryRunner = layerQueryRunner.pipe( + Layer.provide(PortScanner.layerTest), Layer.provide( Layer.succeed(ProviderEventLoggers, { native: undefined, @@ -487,4 +490,54 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro yield* Effect.promise(() => turnStopped); }), ); + + it.effect("marks the CLI's jobs with its thread and counts it as an agent until it exits", () => { + let runningAgents = 0; + let releaseAgent!: () => void; + const agentReleased = new Promise((resolve) => (releaseAgent = resolve)); + const runner = layerQueryRunner.pipe( + Layer.provide( + Layer.mock(PortScanner.PortDiscovery)({ + trackAgent: () => { + runningAgents += 1; + return () => { + runningAgents -= 1; + releaseAgent(); + }; + }, + }), + ), + Layer.provide( + Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined }), + ), + Layer.provide(NodeServices.layer), + ); + return Effect.gen(function* () { + const cli = fakeClaudeCli( + [ + 'dir=$(dirname "$0")', + `printf '%s' "$${PortScanner.AGENT_THREAD_ENV}" > "$dir/thread"`, + "cat > /dev/null", + ].join("\n"), + ); + const dir = NodePath.dirname(cli); + NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "thread")]); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })), + ); + const threadRead = new Promise((resolve) => { + let thread = ""; + NodeFS.createReadStream(NodePath.join(dir, "thread"), "utf8") + .on("data", (chunk) => (thread += chunk)) + .once("end", () => resolve(thread)); + }); + + const session = yield* openFakeCli(cli); + assert.equal(yield* Effect.promise(() => threadRead), "thread-fake-cli"); + assert.equal(runningAgents, 1); + yield* session.close; + yield* Effect.promise(() => agentReleased); + assert.equal(runningAgents, 0); + }).pipe(Effect.provide(runner)); + }); }); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 3ea5a4c810a3f..903e9a7010094 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,9 +1,14 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the stop test drives real server processes. +import * as NodeChildProcess from "node:child_process"; + import { it as effectIt } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH, type DiscoveredLocalServer, } from "@t3tools/contracts"; +import * as ProcessGroup from "@t3tools/provider-core/server/processGroup"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Duration from "effect/Duration"; @@ -14,11 +19,14 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; -import { expect } from "vite-plus/test"; +import { expect, vi } from "vite-plus/test"; import { FetchHttpClient } from "effect/http"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; + +vi.mock("@t3tools/provider-core/server/processGroup", { spy: true }); + const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (input) => Effect.fail( new ProcessRunner.ProcessSpawnError({ @@ -496,3 +504,307 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits expect(yield* scanner.scan()).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({}); + const layer = layerProbeFailure( + (input) => + input.command === "lsof" + ? processProbeFailure(input) + : Effect.succeed(processOutput("4242 1 4300\n5000 1 5300\n")), + () => Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })), + FileSystem.layerNoop({ + readFileString: (path) => { + if (path === "/proc/net/tcp") { + return Effect.succeed(PROC_NET_TCP.split("\n").slice(0, 2).join("\n")); + } + if (/^\/proc\/(4242|5000)\/comm$/.test(path)) return Effect.succeed("node\n"); + if (/^\/proc\/(4242|5000)\/environ$/.test(path)) { + return Effect.succeed(`${PortScanner.AGENT_THREAD_ENV}=thread-agent\0`); + } + return missing.readFileString(path); + }, + readDirectory: (path) => { + if (path === "/proc") return Effect.succeed(["4242", "5000"]); + if (/^\/proc\/(4242|5000)\/fd$/.test(path)) { + return Effect.succeed(path === `/proc/${holder}/fd` ? ["3"] : []); + } + return missing.readDirectory(path); + }, + readLink: (path) => + path === `/proc/${holder}/fd/3` ? Effect.succeed("socket:[5001]") : missing.readLink(path), + }), + ); + + return Effect.gen(function* () { + const signal = vi.spyOn(ProcessGroup, "signalProcessGroup").mockImplementation(() => { + throw Object.assign(new Error("No such process"), { code: "ESRCH" }); + }); + yield* Effect.addFinalizer(() => Effect.sync(() => signal.mockRestore())); + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toMatchObject([{ pid: 4242, port: 8765 }]); + + // The socket outlives its original holder. Its old pid now belongs to another agent job. + holder = "5000"; + const result = yield* scanner + .stopServer({ pid: 4242, port: 8765 }) + .pipe(Effect.match({ onFailure: (error) => error.reason, onSuccess: () => null })); + expect(signal).not.toHaveBeenCalled(); + expect(result).toBe("not-found"); + expect(yield* scanner.scan()).toMatchObject([{ pid: 5000, port: 8765 }]); + yield* scanner.stopServer({ pid: 5000, port: 8765 }); + expect(signal).toHaveBeenCalledExactlyOnceWith(5300, "SIGTERM"); + }).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) => { + const agentPort = 43_124; + const otherPort = 43_125; + const agentListener = `p4242\ncnode\nn127.0.0.1:${agentPort}\n`; + const otherListener = `p5000\ncpostgres\nn127.0.0.1:${otherPort}\n`; + let lsofStdout = otherListener; + let lsofFails = false; + let environmentFails = false; + let lsofCalls = 0; + let environmentReads = 0; + const requests: string[] = []; + const missing = FileSystem.makeNoop({}); + const procEnviron: Record = { + "/proc/4242/environ": `PATH=/usr/bin\0${PortScanner.AGENT_THREAD_ENV}=thread-agent\0`, + "/proc/5000/environ": "PATH=/usr/bin\0", + }; + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + const content = procEnviron[path]; + if (content === undefined) return missing.readFileString(path); + environmentReads += 1; + return Effect.succeed(content); + }, + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + if (input.command === "lsof") { + lsofCalls += 1; + return lsofFails + ? Effect.fail( + new ProcessRunner.ProcessTimeoutError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + timeoutMs: 5_000, + }), + ) + : Effect.succeed(processOutput(lsofStdout)); + } + // A direct child of this process, so stop refuses it without signaling anything. + if (input.args[0] === "-A") { + return Effect.succeed(processOutput(`4242 ${process.pid} 4242\n`)); + } + if (environmentFails) { + return Effect.fail( + new ProcessRunner.ProcessTimeoutError({ + command: input.command, + argumentCount: input.args.length, + timeoutMs: 5_000, + }), + ); + } + environmentReads += 1; + return Effect.succeed( + processOutput( + ` 4242 node server.js PATH=/usr/bin ${PortScanner.AGENT_THREAD_ENV}=thread-agent HOME=/Users/dev\n` + + " 5000 postgres -D /data PATH=/usr/bin\n", + ), + ); + }, + }), + Layer.succeed(HostProcess.Platform, platform), + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch, (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response("app", { headers: { "content-type": "text/html" } }), + ); + }), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + // Startup reads listeners once. With none marked, later scans stay idle. + expect(yield* scanner.scan()).toEqual([]); + expect(yield* scanner.scan()).toEqual([]); + expect(lsofCalls).toBe(1); + + // The agent exits before any client looks, and the first read fails; its server is still + // found. + lsofStdout = `${agentListener}${otherListener}`; + scanner.trackAgent()(); + lsofFails = true; + expect(yield* scanner.scan()).toEqual([]); + lsofFails = false; + if (platform === "darwin") { + environmentFails = true; + expect(yield* scanner.scan()).toEqual([]); + environmentFails = false; + } + expect(yield* scanner.scan()).toMatchObject([ + { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, + ]); + expect(requests).toEqual([`http://localhost:${agentPort}/`]); + + expect(yield* scanner.scan()).toHaveLength(1); + expect(environmentReads).toBe(2); + + // Stop reads its target's environment again rather than trust the cache, and still finds it. + const stop = yield* Effect.flip(scanner.stopServer({ pid: 4242, port: agentPort })); + expect(stop.reason).toBe("shared-group"); + expect(environmentReads).toBe(3); + + lsofStdout = otherListener; + expect(yield* scanner.scan()).toEqual([]); + const lsofCallsAfterExit = lsofCalls; + expect(yield* scanner.scan()).toEqual([]); + expect(lsofCalls).toBe(lsofCallsAfterExit); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("stops only a job's own process group", () => + Effect.sync(() => { + const serverPid = 100; + const rows = [ + { pid: 10, ppid: 1, pgid: 10 }, // `vp run dev`, whose job holds this server + { pid: 20, ppid: 10, pgid: 10 }, // the web dev server in the same job + { pid: serverPid, ppid: 10, pgid: 10 }, + { pid: 200, ppid: serverPid, pgid: 200 }, // agent CLI + { pid: 210, ppid: 200, pgid: 200 }, // a server it spawned without a group of its own + { pid: 300, ppid: 200, pgid: 300 }, // agent tool shell + { pid: 400, ppid: 300, pgid: 400 }, // `npm run dev &` + { pid: 401, ppid: 400, pgid: 400 }, + { pid: 500, ppid: serverPid, pgid: 500 }, // terminal shell + { pid: 600, ppid: 500, pgid: 600 }, // terminal job + ]; + const table = new Map(rows.map((row) => [row.pid, row])); + expect(PortScanner.stoppableProcessGroup(table, 401, serverPid)).toBe(400); + expect(PortScanner.stoppableProcessGroup(table, 600, serverPid)).toBe(600); + for (const pid of [20, 210, 500, 999]) { + expect(PortScanner.stoppableProcessGroup(table, pid, serverPid)).toBeNull(); + } + }), +); + +const AGENT_SERVER_SCRIPT = ` +const server = require("node:http").createServer((_, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end(""); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write(process.pid + " " + server.address().port + "\\n"); +}); +`; + +/** + * A real HTML server with an agent's environment, started the way an agent's tool shell starts a + * job: under a detached shell with job control, in a group of its own. Release kills only the pids + * it started. + */ +const agentServer = () => + Effect.acquireRelease( + Effect.callback<{ + readonly child: NodeChildProcess.ChildProcess; + readonly pid: number; + readonly port: number; + readonly exited: Promise; + }>((resume) => { + const env = { ...process.env, [PortScanner.AGENT_THREAD_ENV]: "thread-live" }; + // dash ignores `set -m` without a terminal; bash, like agent tool shells, honors it. + const child = NodeChildProcess.spawn( + "bash", + ["-c", 'set -m; "$0" -e "$1" & wait', process.execPath, AGENT_SERVER_SCRIPT], + { detached: true, env, stdio: ["ignore", "pipe", "ignore"] }, + ); + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + child.once("error", (error) => resume(Effect.die(error))); + child.stdout?.once("data", (chunk) => { + const [pid, port] = String(chunk).trim().split(" ").map(Number); + resume(Effect.succeed({ child, pid: pid!, port: port!, exited })); + }); + }), + ({ child, pid }) => + Effect.sync(() => { + for (const target of [pid, child.pid]) { + try { + if (target !== undefined) process.kill(target, "SIGKILL"); + } catch {} + } + }), + ); + +const isRunning = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +effectIt.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "stops an agent's server through its job's group, and only a listed server", + () => + Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* Effect.acquireRelease(Effect.sync(scanner.trackAgent), (release) => + Effect.sync(release), + ); + const job = yield* agentServer(); + + expect(yield* scanner.scan()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pid: job.pid, + port: job.port, + agent: { threadId: "thread-live" }, + }), + ]), + ); + + const unlisted = yield* Effect.flip(scanner.stopServer({ pid: process.pid, port: job.port })); + expect(unlisted.reason).toBe("not-found"); + + yield* scanner.stopServer({ pid: job.pid, port: job.port }); + // The shell waits on its job, so it exits once the job has. + yield* Effect.promise(() => job.exited); + expect(isRunning(job.pid)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + PortScanner.layer.pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ), + ), + ), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index bd4a3c3733bfe..1080624d7508d 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -1,9 +1,12 @@ import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH, + PreviewStopLocalServerError, ThreadId, type DiscoveredLocalServer, + type PreviewStopLocalServerInput, } from "@t3tools/contracts"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; @@ -46,9 +49,25 @@ export class PortDiscovery extends Context.Service< readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + /** + * Counts a running agent until the returned function is called. While one runs, any + * listener may be a server it started. Synchronous because provider spawn callbacks run + * outside Effect. + */ + readonly trackAgent: () => () => void; + /** Stops a listed server's process group, never this server, an agent CLI, or a shell. */ + readonly stopServer: ( + input: PreviewStopLocalServerInput, + ) => Effect.Effect; } >()("t3/preview/PortScanner/PortDiscovery") {} +/** + * Agents launch with this set to their thread, and every job they start inherits it, so a + * server stays attributed after its launching shell exits or the agent settles. + */ +export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; + const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; @@ -63,6 +82,10 @@ const WINDOWS_PROCESS_NAME_TTL_MS = Duration.toMillis(Duration.seconds(60)); const WINDOWS_PROCESS_NAME_CONCURRENCY = 4; const WEB_PROBE_CONCURRENCY = 16; const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const PS_TIMEOUT_MS = 5_000; +const PS_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const STOP_GRACE_MS = 3_000; +const STOP_POLL_INTERVAL = Duration.millis(250); type Listener = (servers: ReadonlyArray) => Effect.Effect; @@ -88,6 +111,19 @@ interface TerminalProcessOwner { readonly terminalId: string; } +type ServerOwner = Pick; + +const UNOWNED: ServerOwner = { terminal: null, agent: null }; + +const isOwned = (server: DiscoveredLocalServer): boolean => + server.terminal !== null || (server.agent ?? null) !== null; + +interface ProcessTableRow { + readonly pid: number; + readonly ppid: number; + readonly pgid: number; +} + interface WebProbeCacheEntry { readonly pid: number | null; readonly isWeb: boolean; @@ -170,7 +206,7 @@ const projectWebProbeSnapshot = ( const parseLsofOutput = ( raw: string, - terminalByProcessId: ReadonlyMap = new Map(), + owners: ReadonlyMap = new Map(), ): ReadonlyArray => { const seen = new Map(); let pid: number | null = null; @@ -195,10 +231,10 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const owner = pid === null ? undefined : owners.get(pid); const existing = seen.get(key); if (existing) { - if (terminal === null) seen.set(key, { ...existing, terminal: null }); + if (owner === undefined) seen.set(key, { ...existing, ...UNOWNED }); continue; } seen.set(key, { @@ -207,7 +243,7 @@ const parseLsofOutput = ( url, processName, pid, - terminal, + ...(owner ?? UNOWNED), }); } } @@ -288,18 +324,17 @@ const parseTasklistProcessName = (raw: string, pid: number): string | null => { const parseWindowsListenerOutput = ( raw: string, - terminalByProcessId: ReadonlyMap = new Map(), + owners: ReadonlyMap = new Map(), ): ReadonlyArray => { const seen = new Map(); for (const line of raw.split(/\r?\n/g)) { const parsed = parseWindowsListenerLine(line); if (!parsed) continue; const { port, pid: normalizedPid, processName } = parsed; - const terminal = - normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const owner = normalizedPid === null ? undefined : owners.get(normalizedPid); const existing = seen.get(port); if (existing) { - if (terminal === null) seen.set(port, { ...existing, terminal: null }); + if (owner === undefined) seen.set(port, { ...existing, ...UNOWNED }); continue; } seen.set(port, { @@ -308,7 +343,7 @@ const parseWindowsListenerOutput = ( url: `http://localhost:${port}`, processName, pid: normalizedPid, - terminal, + ...(owner ?? UNOWNED), }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -343,6 +378,90 @@ const parseProcNetTcp = ( return listeners; }; +/** Processes holding a loopback or wildcard listener in `lsof -F pcn` output. */ +const lsofProcessIds = (raw: string): ReadonlyArray => { + const pids = new Set(); + let pid: number | null = null; + for (const line of raw.split("\n")) { + if (line.startsWith("p")) { + const parsed = Number.parseInt(line.slice(1), 10); + pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } else if ( + line.startsWith("n") && + pid !== null && + parsePortFromLsofName(line.slice(1)) !== null + ) { + pids.add(pid); + } + } + return [...pids]; +}; + +const AGENT_THREAD_ASSIGNMENT = new RegExp(`(?:^|\\s)${AGENT_THREAD_ENV}=(\\S+)`); + +/** Reads `/proc//environ`, whose entries are NUL-separated. */ +const parseProcEnvironThread = (raw: string): ThreadId | null => { + const prefix = `${AGENT_THREAD_ENV}=`; + const value = raw + .split("\0") + .find((entry) => entry.startsWith(prefix)) + ?.slice(prefix.length) + .trim(); + return value ? ThreadId.make(value) : null; +}; + +/** + * Reads macOS `ps -E -o pid=,command=`, which appends each process's launch environment to its + * argv with spaces, so an argument that spells the assignment out also matches. + */ +const parsePsEnvironmentThreads = (raw: string): ReadonlyMap => { + const threads = new Map(); + for (const line of raw.split("\n")) { + const match = /^\s*(\d+)\s(.*)$/.exec(line); + if (!match) continue; + const value = AGENT_THREAD_ASSIGNMENT.exec(match[2]!)?.[1]; + threads.set(Number(match[1]), value ? ThreadId.make(value) : null); + } + return threads; +}; + +const parseProcessTable = (raw: string): ReadonlyMap => { + const rows = new Map(); + for (const line of raw.split("\n")) { + const [pid, ppid, pgid] = line.trim().split(/\s+/).map(Number); + if (Number.isInteger(pid) && Number.isInteger(ppid) && Number.isInteger(pgid)) { + rows.set(pid!, { pid: pid!, ppid: ppid!, pgid: pgid! }); + } + } + return rows; +}; + +/** + * The process group stopping `pid` signals. Shells with job control and agent tool shells give + * every job its own group, so the group is the job. Null when the group also holds this server, + * a direct child of it (agent CLIs and terminal shells), or one of its ancestors. + */ +export const stoppableProcessGroup = ( + table: ReadonlyMap, + pid: number, + serverPid: number, +): number | null => { + const group = table.get(pid)?.pgid; + if (group === undefined || group <= 1) return null; + const guarded = new Set([serverPid]); + for ( + let parent = table.get(serverPid)?.ppid; + parent !== undefined && parent > 1 && !guarded.has(parent); + parent = table.get(parent)?.ppid + ) { + guarded.add(parent); + } + for (const row of table.values()) { + if (row.pgid === group && (guarded.has(row.pid) || row.ppid === serverPid)) return null; + } + return group; +}; + const serversEqual = ( left: ReadonlyArray, right: ReadonlyArray, @@ -359,7 +478,8 @@ const serversEqual = ( a.processName !== b.processName || a.pid !== b.pid || a.terminal?.threadId !== b.terminal?.threadId || - a.terminal?.terminalId !== b.terminal?.terminalId + a.terminal?.terminalId !== b.terminal?.terminalId || + a.agent?.threadId !== b.agent?.threadId ) { return false; } @@ -367,6 +487,9 @@ const serversEqual = ( return true; }; +const isNoSuchProcess = (cause: unknown): boolean => + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ESRCH"; + const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound"; @@ -391,6 +514,72 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ReadonlyMap >(new Map()); const socketOwnerMissesRef = yield* Ref.make>(new Map()); + // Each current listener pid's agent thread, or null without one. A process keeps its launch + // environment, so a pid is read once while it listens. Cleared while no client retains the + // scanner, since unscanned pids can be reused. + const agentThreadsRef = yield* Ref.make>(new Map()); + let runningAgents = 0; + // An agent that exited, or a cache cleared with a marked listener, owes one listener read: the + // servers it left may still run. So does startup, for agents that ran before a restart. + let agentScanPending = true; + + const readAgentThreads = Effect.fn("PortDiscovery.readAgentThreads")(function* ( + pids: ReadonlyArray, + ) { + const cached = yield* Ref.get(agentThreadsRef); + const unread = pids.filter((pid) => !cached.has(pid)); + const read: ReadonlyMap = + unread.length === 0 + ? new Map() + : hostPlatform === "linux" + ? new Map( + yield* Effect.forEach( + unread, + (pid) => + fileSystem.readFileString(`/proc/${pid}/environ`).pipe( + Effect.map(parseProcEnvironThread), + Effect.orElseSucceed(() => null), + Effect.map((threadId) => [pid, threadId] as const), + ), + { concurrency: 16 }, + ), + ) + : yield* processRunner + .run({ + command: "ps", + args: ["-E", "-ww", "-o", "pid=,command=", "-p", unread.join(",")], + timeout: Duration.millis(PS_TIMEOUT_MS), + maxOutputBytes: PS_MAX_OUTPUT_BYTES, + }) + .pipe( + Effect.map((result) => parsePsEnvironmentThreads(result.stdout)), + Effect.orElseSucceed(() => { + agentScanPending = true; + return new Map(); + }), + ); + const next = new Map(); + for (const pid of pids) { + const threadId = cached.has(pid) ? cached.get(pid) : read.get(pid); + if (threadId !== undefined) next.set(pid, threadId); + } + yield* Ref.set(agentThreadsRef, next); + return next; + }); + + const withAgentOwners = Effect.fn("PortDiscovery.withAgentOwners")(function* ( + terminalOwners: ReadonlyMap, + pids: ReadonlyArray, + ) { + // Runs only once a listener snapshot exists, so a failed read leaves the scan pending. + agentScanPending = false; + const threads = yield* readAgentThreads(pids.filter((pid) => !terminalOwners.has(pid))); + const owners = new Map(terminalOwners); + for (const [pid, threadId] of threads) { + if (threadId !== null) owners.set(pid, { terminal: null, agent: { threadId } }); + } + return owners; + }); const findSocketOwners = Effect.fn("PortDiscovery.findSocketOwners")(function* ( inodes: ReadonlySet, @@ -428,7 +617,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }); const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( - terminalByProcessId: ReadonlyMap, + resolveOwners: (pids: ReadonlyArray) => Effect.Effect>, ) { if (hostPlatform !== "linux") return null; const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) => @@ -455,22 +644,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } yield* Ref.set(socketOwnersRef, owners); yield* Ref.set(socketOwnerMissesRef, misses); + const serverOwners = yield* resolveOwners([ + ...new Set([...owners.values()].map(({ pid }) => pid)), + ]); const seen = new Map(); for (const { port, inode } of listeners) { - const owner = owners.get(inode) ?? null; - const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); + const socketOwner = owners.get(inode) ?? null; + const owner = socketOwner === null ? undefined : serverOwners.get(socketOwner.pid); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) { - if (terminal === null) seen.set(port, { ...existing, terminal: null }); + if (existing && (existing.pid !== null || socketOwner === null)) { + if (owner === undefined) seen.set(port, { ...existing, ...UNOWNED }); continue; } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, - processName: owner?.processName ?? null, - pid: owner?.pid ?? null, - terminal: existing ? null : terminal, + processName: socketOwner?.processName ?? null, + pid: socketOwner?.pid ?? null, + ...(existing ? UNOWNED : (owner ?? UNOWNED)), }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -530,7 +722,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { for (const server of servers) { // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. - if (server.terminal === null) continue; + if (!isOwned(server)) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -682,15 +874,24 @@ export const make = Effect.gen(function* PortDiscoveryMake() { configuredUrls: ReadonlyArray, ) { const state = yield* Ref.get(stateRef); - const terminalByProcessId = new Map(); + const terminalOwners = new Map(); for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { - terminalByProcessId.set(processId, registration.owner); + terminalOwners.set(processId, { terminal: registration.owner, agent: null }); } } - if (terminalByProcessId.size === 0 && configuredUrls.length === 0) { + // A server an agent started can outlive the agent, so listeners are read while the last scan + // still found one. + const agentsPossible = + hostPlatform !== "win32" && + (runningAgents > 0 || + agentScanPending || + [...(yield* Ref.get(agentThreadsRef)).values()].some((threadId) => threadId !== null)); + if (terminalOwners.size === 0 && configuredUrls.length === 0 && !agentsPossible) { return { discovered: [], configured: new Map() } satisfies WebProbeSnapshot; } + const resolveOwners = (pids: ReadonlyArray) => + agentsPossible ? withAgentOwners(terminalOwners, pids) : Effect.succeed(terminalOwners); if (hostPlatform === "win32") { const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const runNetstat = (protocol: "tcp" | "tcpv6") => @@ -707,7 +908,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { Effect.map(([tcp4, tcp6]) => exitedWithError(tcp4) || exitedWithError(tcp6) ? null - : parseWindowsListenerOutput(`${tcp4.stdout}\n${tcp6.stdout}`, terminalByProcessId), + : parseWindowsListenerOutput(`${tcp4.stdout}\n${tcp6.stdout}`, terminalOwners), ), Effect.catchTags({ ProcessSpawnError: recoverWindowsProbeFailure, @@ -723,7 +924,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { - const fromProc = yield* scanProcListeners(terminalByProcessId); + const fromProc = yield* scanProcListeners(resolveOwners); return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner @@ -735,7 +936,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { maxOutputBytes: 1024 * 1024, }) .pipe( - Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.flatMap((result) => + resolveOwners(lsofProcessIds(result.stdout)).pipe( + Effect.map((owners) => parseLsofOutput(result.stdout, owners)), + ), + ), Effect.catchTags({ ProcessSpawnError: (error) => (isCommandNotFound(error) ? Ref.set(lsofMissingRef, true) : Effect.void).pipe( @@ -748,7 +953,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); - const fromProc = yield* scanProcListeners(terminalByProcessId); + const fromProc = yield* scanProcListeners(resolveOwners); return yield* probeWebServers(fromProc ?? [], configuredUrls); }); @@ -809,11 +1014,19 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } }); - const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(acquireRetention(), () => - Ref.update(stateRef, (state) => ({ - ...state, - retainCount: Math.max(0, state.retainCount - 1), - })), + const releaseRetention = Effect.gen(function* () { + const idle = yield* Ref.modify(stateRef, (state) => { + const retainCount = Math.max(0, state.retainCount - 1); + return [retainCount === 0, { ...state, retainCount }]; + }); + if (!idle) return; + const threads = yield* Ref.getAndSet(agentThreadsRef, new Map()); + if ([...threads.values()].some((threadId) => threadId !== null)) agentScanPending = true; + }); + + const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease( + acquireRetention(), + () => releaseRetention, ); const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( @@ -867,13 +1080,109 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }); }); + const trackAgent: PortDiscovery["Service"]["trackAgent"] = () => { + runningAgents += 1; + let released = false; + return () => { + if (released) return; + released = true; + runningAgents -= 1; + agentScanPending = true; + }; + }; + + // Fails when `ps` does, so an empty table never reads as every process having exited. + const readProcessTable = Effect.suspend(() => + processRunner.run({ + command: "ps", + args: ["-A", "-o", "pid=,ppid=,pgid="], + timeout: Duration.millis(PS_TIMEOUT_MS), + maxOutputBytes: PS_MAX_OUTPUT_BYTES, + }), + ).pipe( + Effect.mapError((cause) => new PreviewStopLocalServerError({ reason: "signal-failed", cause })), + Effect.filterOrFail( + (result) => !exitedWithError(result), + () => new PreviewStopLocalServerError({ reason: "signal-failed" }), + ), + Effect.map((result) => parseProcessTable(result.stdout)), + ); + + const signalUntilStopped = Effect.fn("PortDiscovery.signalUntilStopped")(function* ( + input: PreviewStopLocalServerInput, + ) { + if (hostPlatform === "win32") { + return yield* new PreviewStopLocalServerError({ reason: "unsupported" }); + } + // A socket can outlive its cached owner. Wait for any in-flight scan before invalidating + // that owner, so the /proc fallback confirms who holds it before a signal is sent. + yield* scanSemaphore.withPermits(1)( + Ref.update( + socketOwnersRef, + (owners) => new Map([...owners].filter(([, owner]) => owner.pid !== input.pid)), + ), + ); + // The pid comes from a client, so only a server this scan attributes to a thread is signaled, + // from its environment as read now. + yield* Ref.update(agentThreadsRef, (threads) => { + const next = new Map(threads); + next.delete(input.pid); + return next; + }); + agentScanPending = true; + const listed = (yield* scanOnce()).some( + (server) => server.pid === input.pid && server.port === input.port && isOwned(server), + ); + if (!listed) return yield* new PreviewStopLocalServerError({ reason: "not-found" }); + const table = yield* readProcessTable; + // It exited after the scan, so there is nothing left to stop. + if (!table.has(input.pid)) return; + const group = stoppableProcessGroup(table, input.pid, process.pid); + if (group === null) return yield* new PreviewStopLocalServerError({ reason: "shared-group" }); + // False when the whole group already exited. + const signalGroup = (signal: "SIGTERM" | "SIGKILL") => + Effect.suspend(() => { + try { + signalProcessGroup(group, signal); + return Effect.succeed(true); + } catch (cause) { + return isNoSuchProcess(cause) + ? Effect.succeed(false) + : Effect.fail(new PreviewStopLocalServerError({ reason: "signal-failed", cause })); + } + }); + if (!(yield* signalGroup("SIGTERM"))) return; + // While one of the group's original members is still in it, the group has not been reused, + // so SIGKILL goes only to the job that ignored SIGTERM. + const members = [...table.values()].filter((row) => row.pgid === group).map((row) => row.pid); + const jobRunning = readProcessTable.pipe( + Effect.map((current) => members.some((pid) => current.get(pid)?.pgid === group)), + Effect.orElseSucceed(() => false), + ); + const deadline = (yield* Clock.currentTimeMillis) + STOP_GRACE_MS; + while ((yield* Clock.currentTimeMillis) < deadline) { + yield* Effect.sleep(STOP_POLL_INTERVAL); + if (!(yield* jobRunning)) return; + } + yield* signalGroup("SIGKILL"); + }); + + const stopServer: PortDiscovery["Service"]["stopServer"] = (input) => + // Subscribers see the server leave now rather than on the next poll. + signalUntilStopped(input).pipe(Effect.andThen(pollIfRetained)); + return PortDiscovery.of({ scan: scanOnce, subscribe, retain, registerTerminalProcesses, unregisterTerminal, + trackAgent, + stopServer, }); }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); + +/** A scanner that tracks nothing, for tests that build provider runners without a host. */ +export const layerTest = Layer.mock(PortDiscovery)({ trackAgent: () => () => undefined }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 0315cb8dfd83d..6b75b543bc26e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -36,6 +36,7 @@ import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -191,6 +192,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ); const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( + Layer.provide(PortScanner.layerTest), Layer.provideMerge(layerBase), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 4ea8042d02ef1..74f189bf03516 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -21,6 +21,7 @@ import * as ProviderHostLive from "./ProviderHostLive.ts"; import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import type * as ServerConfig from "../config.ts"; +import type * as PortScanner from "../preview/PortScanner.ts"; type ProviderInstanceRegistryHydrationEnv = | Exclude< @@ -31,7 +32,9 @@ type ProviderInstanceRegistryHydrationEnv = > | Settings.ServerSettingsService | BackgroundPolicy.BackgroundPolicy - | ServerConfig.ServerConfig; + | ServerConfig.ServerConfig + // The Claude runner counts its CLIs as running agents for port discovery. + | PortScanner.PortDiscovery; export const deriveProviderInstanceConfigMap = ( settings: ServerSettings, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 1c47a2f31c3fe..0b08b79e65987 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -51,6 +51,7 @@ import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRunt import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettingsModule from "../serverSettings.ts"; import { @@ -1961,7 +1962,9 @@ it.layer( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge( + ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(PortScanner.layerTest)), + ), Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), @@ -2077,7 +2080,9 @@ it.layer( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge( + ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(PortScanner.layerTest)), + ), Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), @@ -2210,9 +2215,9 @@ it.layer( ), ); readSeed = false; - const services = yield* Layer.build(ProviderInstanceRegistryHydration.layer).pipe( - Effect.provide(dependencies), - ); + const services = yield* Layer.build( + ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(PortScanner.layerTest)), + ).pipe(Effect.provide(dependencies)); failCurrentRead = input.failCurrentRead ?? false; yield* PubSub.publish(changes, undefined); yield* Deferred.succeed(consume, undefined); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b6bf149e801c8..b84b9eb78568c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -634,6 +634,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ), Layer.provideMerge(ProviderRegistry.layer), Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + // Provider hydration builds the Claude runner, which reports agents to the same scanner the + // terminal and preview layers share. + Layer.provideMerge(layerPortScanner), Layer.provideMerge( Layer.mergeAll( AntigravityInstallation.AntigravityInstallation.layer, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ebd38173533ec..3f3c8718e4c12 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -423,7 +423,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(PairingGrantStore.PairingGrantStore)({}), Layer.mock(PeerLinks.PeerLinks)({}), Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), - Layer.mock(PortScanner.PortDiscovery)({}), + Layer.mock(PortScanner.PortDiscovery)({ trackAgent: () => () => undefined }), Layer.mock(PreviewManager.PreviewManager)({}), Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({}), Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 66f39ab229847..f686ca000dbaf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3101,6 +3101,7 @@ export const layerWsRpc = ( [WS_METHODS.deviceDetail]: (input) => deviceService.detail(input), [WS_METHODS.deviceAction]: (input) => deviceService.action(input), [WS_METHODS.subscribeDeviceState]: (_input) => DeviceService.stateStream(deviceService), + [WS_METHODS.previewStopLocalServer]: (input) => portDiscovery.stopServer(input), [WS_METHODS.subscribeDiscoveredLocalServers]: (input) => Stream.callback((queue) => Effect.gen(function* () { diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 3e62dd8cd5a44..e51f62f34b056 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -22,6 +22,7 @@ import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; +import { ThreadServersPanel } from "./ThreadServersPanel"; const GitActionsControl = memo(BaseGitActionsControl); @@ -181,6 +182,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null} + {density === "full" && !props.draftId ? ( + + ) : null} + {density === "full" && !props.draftId ? ( ) : null} diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx new file mode 100644 index 0000000000000..1a6fed82aa5bd --- /dev/null +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -0,0 +1,125 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + AuthPreviewOperateScope, + type DiscoveredLocalServer, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { GlobeIcon, SquareIcon } from "lucide-react"; +import { useState } from "react"; + +import { useThreadDiscoveredPorts } from "../../portDiscoveryState"; +import { useEnvironment } from "../../state/environments"; +import { previewEnvironment } from "../../state/preview"; +import { useEnvironmentScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { openDiscoveredPort } from "../preview/openDiscoveredPort"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { ThreadDetailsSection } from "./ThreadDetailsSection"; +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. */ +export function ThreadServersPanel(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const servers = useThreadDiscoveredPorts(props); + const canOpen = useEnvironmentScope(props.environmentId, AuthPreviewOperateScope); + const canStop = useAtomValue( + previewEnvironment.stopLocalServer.permissionAtom(props.environmentId), + ); + const supportsStop = + useEnvironment(props.environmentId)?.serverConfig?.environment.capabilities.localServerStop === + true; + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const stopServer = useAtomCommand(previewEnvironment.stopLocalServer, { + label: "thread server stop", + }); + const [stoppingKey, setStoppingKey] = useState(null); + + if (servers.length === 0) return null; + const threadRef = scopeThreadRef(props.environmentId, props.threadId); + + const reportFailure = (title: string, error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : String(error), + }), + ); + }; + + const open = async (server: DiscoveredLocalServer) => { + const result = await openDiscoveredPort({ threadRef, port: server, openPreview }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure("Unable to open preview", squashAtomCommandFailure(result)); + } + }; + + const stop = async (server: DiscoveredLocalServer) => { + if (server.pid === null || stoppingKey !== null) return; + setStoppingKey(serverKey(server)); + const result = await stopServer({ + environmentId: props.environmentId, + input: { pid: server.pid, port: server.port }, + }); + setStoppingKey(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure("Could not stop server", squashAtomCommandFailure(result)); + } + }; + + return ( + +
    + {servers.map((server) => ( +
  • + void open(server)} + > + + localhost:{server.port} + + {[server.terminal === null ? "Agent" : "Terminal", server.processName] + .filter(Boolean) + .join(" · ")} + + + {canStop && supportsStop && server.pid !== null ? ( + + void stop(server)} + > + + + } + /> + Stop server + + ) : null} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index e59ca6dea5f4d..5eb3698eaa224 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -72,6 +72,9 @@ export function useThreadDiscoveredPorts(input: { }): ReadonlyArray { const ports = useDiscoveredPorts(input.environmentId); return input.threadId - ? ports.filter((port) => port.terminal?.threadId === input.threadId) + ? ports.filter( + (port) => + port.terminal?.threadId === input.threadId || port.agent?.threadId === input.threadId, + ) : EMPTY_PORTS; } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 8cfee144b444e..8cf788c5a525b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -251,6 +251,13 @@ 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 finishes or the thread settles. +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. + ## Snooze until later Choose **Snooze → Custom…** from a thread's menu to pick a date and time in your diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 99f3b2f10e1d4..479e557cee157 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -84,6 +84,10 @@ export function createPreviewEnvironmentAtoms( tag: WS_METHODS.previewReportProfiles, concurrency: { mode: "latest", key: ({ environmentId }) => environmentId }, }), + stopLocalServer: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:stop-local-server", + tag: WS_METHODS.previewStopLocalServer, + }), reportStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:report-status", tag: WS_METHODS.previewReportStatus, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 364b3d191af37..b2d66d902e1e7 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -7,6 +7,7 @@ import { AuthPreviewOperateScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, + AuthTerminalOperateScope, type AuthEnvironmentScope, } from "./auth.ts"; import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts"; @@ -15,6 +16,8 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.previewClose]: AuthPreviewOperateScope, + // Stopping a server signals its processes, as closing a terminal does. + [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f3f5ee18c2df..0cd36339bcad0 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -115,6 +115,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadStop: Schema.optionalKey(Schema.Boolean), + /** Absent on older servers and on Windows hosts, which cannot stop a discovered server. */ + localServerStop: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index fb5432986b32f..726c1a88f81af 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -312,9 +312,11 @@ export const PreviewEvent = Schema.Union([ ]); export type PreviewEvent = typeof PreviewEvent.Type; +const LocalServerPort = Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)); + export const DiscoveredLocalServer = Schema.Struct({ host: TrimmedNonEmptyString, - port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + port: LocalServerPort, url: Url, processName: Schema.NullOr(TrimmedNonEmptyString), pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), @@ -324,9 +326,38 @@ export const DiscoveredLocalServer = Schema.Struct({ terminalId: TrimmedNonEmptyString, }), ), + /** Started by the thread's agent. Older servers omit it. */ + agent: Schema.optionalKey(Schema.NullOr(Schema.Struct({ threadId: ThreadId }))), }); export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; +export const PreviewStopLocalServerInput = Schema.Struct({ + pid: PositiveInt, + port: LocalServerPort, +}); +export type PreviewStopLocalServerInput = typeof PreviewStopLocalServerInput.Type; + +export class PreviewStopLocalServerError extends Schema.TaggedError()( + "PreviewStopLocalServerError", + { + reason: Schema.Literals(["not-found", "shared-group", "unsupported", "signal-failed"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + switch (this.reason) { + case "not-found": + return "This server is no longer running."; + case "shared-group": + return "This server shares a process group with T3 Code, an agent, or a terminal shell, so it can't be stopped on its own."; + case "unsupported": + return "Stopping servers is not supported on this environment's platform."; + case "signal-failed": + return "The server's processes could not be signaled."; + } + } +} + export const DiscoveredLocalServerList = Schema.Struct({ servers: Schema.Array(DiscoveredLocalServer), scannedAt: Schema.String, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9c32c6de25bd5..8a9a32cc5f9d4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -282,6 +282,8 @@ import { PreviewClearProfileInput, PreviewReportProfilesInput, PreviewNavigateInput, + PreviewStopLocalServerError, + PreviewStopLocalServerInput, PreviewOpenInput, PreviewRefreshInput, PreviewReportStatusInput, @@ -473,6 +475,7 @@ export const WS_METHODS = { previewClearProfile: "preview.clearProfile", previewReportProfiles: "preview.reportProfiles", previewReportStatus: "preview.reportStatus", + previewStopLocalServer: "preview.stopLocalServer", deviceConfigure: "device.configure", deviceList: "device.list", @@ -1600,6 +1603,11 @@ const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, stream: true, }); +const WsPreviewStopLocalServerRpc = Rpc.make(WS_METHODS.previewStopLocalServer, { + payload: PreviewStopLocalServerInput, + error: Schema.Union([PreviewStopLocalServerError, EnvironmentAuthorizationError]), +}); + const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscoveredLocalServers, { payload: Schema.Struct({ configuredUrls: Schema.optional(ConfiguredLocalServerUrls), @@ -2124,6 +2132,7 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewReportProfilesRpc, WsPreviewReportStatusRpc, WsSubscribePreviewEventsRpc, + WsPreviewStopLocalServerRpc, WsSubscribeDiscoveredLocalServersRpc, WsDeviceConfigureRpc, WsDeviceListRpc, From 79326683d3dedfa395f09b7c07280838810f8ab2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 14:50:23 -0700 Subject: [PATCH 2/2] fix(server,web): servers rows match the redesigned thread details Rows use the subagent row layout: a ghost row control, and a stop button that shows on hover, on focus, and always on touch. Agent tracking moves into the shared Claude spawn wrapper, which main rewrote. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.ts | 7 +- .../components/chat/ThreadServersPanel.tsx | 92 ++++++++++++------- docs/user/thread-sidebar.md | 2 +- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 83f43f39b8f60..a2a4d4b41fd0b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -941,7 +941,7 @@ export const layerQueryRunner: Layer.Layer< ); // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. const { mcpServers, ...sdkOptions } = input.options; - // Jobs the agent starts inherit the thread, so port discovery lists them after the CLI exits. + // Jobs the agent starts inherit the thread, so port discovery can attribute their servers. const queryOptions = { ...sdkOptions, env: { @@ -995,7 +995,10 @@ export const layerQueryRunner: Layer.Layer< const spawnClaudeCodeProcess = (spawnOptions: ClaudeSpawnOptions) => { const child = spawnProcess(spawnOptions); spawnedProcess = child; - if (child.pid !== undefined) child.once("exit", portDiscovery.trackAgent()); + // A spawn that fails emits only "error". + const releaseAgent = portDiscovery.trackAgent(); + child.once("exit", releaseAgent); + child.on("error", releaseAgent); processExited = child.exitCode !== null || child.signalCode != null; processExit = new Promise((resolve) => { if (processExited) resolve(); diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx index 1a6fed82aa5bd..9b7be42eccef7 100644 --- a/apps/web/src/components/chat/ThreadServersPanel.tsx +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -10,9 +10,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { GlobeIcon, SquareIcon } from "lucide-react"; +import { GlobeIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; import { useState } from "react"; +import { cn } from "../../lib/utils"; import { useThreadDiscoveredPorts } from "../../portDiscoveryState"; import { useEnvironment } from "../../state/environments"; import { previewEnvironment } from "../../state/preview"; @@ -82,43 +83,72 @@ export function ThreadServersPanel(props: { return (
    - {servers.map((server) => ( -
  • - void open(server)} - > - - localhost:{server.port} - - {[server.terminal === null ? "Agent" : "Terminal", server.processName] - .filter(Boolean) - .join(" · ")} - - - {canStop && supportsStop && server.pid !== null ? ( + {servers.map((server) => { + const key = serverKey(server); + const stoppable = canStop && supportsStop && server.pid !== null; + return ( +
  • void stop(server)} - > - - + part="row" + disabled={!canOpen} + onClick={() => void open(server)} + /> } - /> - Stop server + > + + + localhost:{server.port} + + {/* The stop button covers this while it shows. */} + + {[server.terminal === null ? "Agent" : "Terminal", server.processName] + .filter(Boolean) + .join(" · ")} + + + Open in the browser panel - ) : null} -
  • - ))} + {stoppable ? ( +
    + + void stop(server)} + /> + } + > + {stoppingKey === key ? ( + + ) : ( + + )} + + Stop server + +
    + ) : null} + + ); + })}
); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 8cf788c5a525b..8e785fad2369d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -253,7 +253,7 @@ 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 finishes or the thread settles. +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.