Skip to content

Commit da93f3e

Browse files
committed
feat(web): detect agent-spawned local servers and surface in sidebar
Adds end-to-end visibility into localhost servers that coding agents start through any tool (terminal pty, Bash background tasks, Monitor tail, etc). The sidebar now shows a pulsing terminal icon when a process is live, and clicking it opens a redesigned dialog listing detected URLs with per-port Open/Stop controls. Server - New `localProcesses.probePorts` RPC and `LocalProcessProbePorts*` contracts that reuse the existing `lsof` / `Get-NetTCPConnection` infrastructure to report listener state without killing anything. - ProviderRuntimeIngestion now buffers `command_output` deltas per thread/turn/item and attaches a `commandActivity` payload (with parsed localhost URLs) to `tool.updated` and `tool.completed` activities. Shared `toolActivity` helpers extract the URLs. Web - `deriveSidebarAgentCommandStatus` scans every `tool.completed` activity on the latest turn so URLs surfaced via Monitor/tail/log tools are captured (not just `command_execution`). - New `useListeningPortProbe` hook ref-counts probe requests per environment and polls `localProcesses.probePorts` every 5s, so rows only render the live icon when a port is actually listening. - `AgentCommandStatusIcon` (sidebar) emerald + pulse when live; click opens the redesigned dialog. - Dialog redesign: subtle backdrop matching the command palette, single URL list with per-row Open + Stop, "Also listening" strip for orphan ports. Stops are scoped to a single port so an agent running multiple servers can have one killed at a time. - `DialogPopup` gains `forceBackdrop` to bypass Base UI's nested- dialog backdrop suppression when opened from the sidebar stack. - Work-log URL chip restyled to neutral pill with sky pulse + arrow. - Dismiss state persisted per thread/status; suppression is bypassed while a process is still observed live.
1 parent f6978db commit da93f3e

29 files changed

Lines changed: 2617 additions & 82 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import {
4+
parseListeningPidList,
5+
stopLocalPorts,
6+
type LocalProcessControls,
7+
} from "./localProcesses.ts";
8+
9+
function makeControls(overrides: Partial<LocalProcessControls> = {}): LocalProcessControls {
10+
return {
11+
currentPid: 999,
12+
listListeningPids: vi.fn(async () => []),
13+
killPid: vi.fn(),
14+
...overrides,
15+
};
16+
}
17+
18+
describe("localProcesses", () => {
19+
it("parses and deduplicates listening process ids", () => {
20+
expect(parseListeningPidList("123\n456\n123 ignored\n")).toEqual([123, 456]);
21+
});
22+
23+
it("stops unique pids for unique ports", async () => {
24+
const controls = makeControls({
25+
listListeningPids: vi.fn(async (port) => (port === 5173 ? [111, 222, 111] : [])),
26+
killPid: vi.fn(),
27+
});
28+
29+
await expect(stopLocalPorts({ ports: [5173, 5173, 3000] }, controls)).resolves.toEqual({
30+
results: [
31+
{ port: 5173, killedPids: [111, 222], errors: [] },
32+
{ port: 3000, killedPids: [], errors: [] },
33+
],
34+
});
35+
expect(controls.killPid).toHaveBeenCalledTimes(2);
36+
});
37+
38+
it("refuses to stop the current T3 Code process", async () => {
39+
const controls = makeControls({
40+
currentPid: 111,
41+
listListeningPids: vi.fn(async () => [111]),
42+
killPid: vi.fn(),
43+
});
44+
45+
const result = await stopLocalPorts({ ports: [5173] }, controls);
46+
47+
expect(result.results[0]?.killedPids).toEqual([]);
48+
expect(result.results[0]?.errors[0]).toContain("Refusing to stop");
49+
expect(controls.killPid).not.toHaveBeenCalled();
50+
});
51+
});

apps/server/src/localProcesses.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type {
2+
LocalProcessProbePortsInput,
3+
LocalProcessProbePortsResult,
4+
LocalProcessStopPortsInput,
5+
LocalProcessStopPortsResult,
6+
} from "@t3tools/contracts";
7+
8+
import { runProcess } from "./processRunner.ts";
9+
10+
const PORT_LOOKUP_TIMEOUT_MS = 2_000;
11+
const PORT_LOOKUP_MAX_BUFFER_BYTES = 64 * 1024;
12+
13+
export interface LocalProcessControls {
14+
readonly listListeningPids: (port: number) => Promise<readonly number[]>;
15+
readonly killPid: (pid: number) => Promise<void> | void;
16+
readonly currentPid: number;
17+
}
18+
19+
export function parseListeningPidList(text: string): number[] {
20+
const seen = new Set<number>();
21+
for (const token of text.split(/\s+/u)) {
22+
if (!/^\d+$/u.test(token)) {
23+
continue;
24+
}
25+
const pid = Number(token);
26+
if (Number.isSafeInteger(pid) && pid > 0) {
27+
seen.add(pid);
28+
}
29+
}
30+
return [...seen];
31+
}
32+
33+
function normalizePorts(input: LocalProcessStopPortsInput): number[] {
34+
const seen = new Set<number>();
35+
for (const port of input.ports) {
36+
if (Number.isInteger(port) && port >= 1 && port <= 65_535) {
37+
seen.add(port);
38+
}
39+
}
40+
return [...seen].slice(0, 16);
41+
}
42+
43+
function normalizeProbePorts(input: LocalProcessProbePortsInput): number[] {
44+
const seen = new Set<number>();
45+
for (const port of input.ports) {
46+
if (Number.isInteger(port) && port >= 1 && port <= 65_535) {
47+
seen.add(port);
48+
}
49+
}
50+
return [...seen].slice(0, 32);
51+
}
52+
53+
async function listListeningPidsWithLsof(port: number): Promise<number[]> {
54+
const result = await runProcess("lsof", ["-nP", "-ti", `TCP:${port}`, "-sTCP:LISTEN"], {
55+
allowNonZeroExit: true,
56+
maxBufferBytes: PORT_LOOKUP_MAX_BUFFER_BYTES,
57+
outputMode: "truncate",
58+
timeoutMs: PORT_LOOKUP_TIMEOUT_MS,
59+
});
60+
return parseListeningPidList(result.stdout);
61+
}
62+
63+
async function listListeningPidsWithPowerShell(port: number): Promise<number[]> {
64+
const command = [
65+
"Get-NetTCPConnection",
66+
`-LocalPort ${port}`,
67+
"-State Listen",
68+
"-ErrorAction SilentlyContinue",
69+
"| Select-Object -ExpandProperty OwningProcess -Unique",
70+
].join(" ");
71+
const result = await runProcess(
72+
"powershell.exe",
73+
["-NoProfile", "-NonInteractive", "-Command", command],
74+
{
75+
allowNonZeroExit: true,
76+
maxBufferBytes: PORT_LOOKUP_MAX_BUFFER_BYTES,
77+
outputMode: "truncate",
78+
timeoutMs: PORT_LOOKUP_TIMEOUT_MS,
79+
},
80+
);
81+
return parseListeningPidList(result.stdout);
82+
}
83+
84+
async function listListeningPids(port: number): Promise<number[]> {
85+
if (process.platform === "win32") {
86+
return listListeningPidsWithPowerShell(port);
87+
}
88+
return listListeningPidsWithLsof(port);
89+
}
90+
91+
function killPid(pid: number): void {
92+
process.kill(pid, "SIGTERM");
93+
}
94+
95+
export const defaultLocalProcessControls: LocalProcessControls = {
96+
currentPid: process.pid,
97+
listListeningPids,
98+
killPid,
99+
};
100+
101+
function errorMessage(error: unknown): string {
102+
return error instanceof Error ? error.message : String(error);
103+
}
104+
105+
export async function probeLocalPorts(
106+
input: LocalProcessProbePortsInput,
107+
controls: LocalProcessControls = defaultLocalProcessControls,
108+
): Promise<LocalProcessProbePortsResult> {
109+
const ports = normalizeProbePorts(input);
110+
const results = await Promise.all(
111+
ports.map(async (port) => {
112+
try {
113+
const pids = await controls.listListeningPids(port);
114+
const filteredPids = [...new Set(pids)].filter(
115+
(pid) => Number.isSafeInteger(pid) && pid > 0 && pid !== controls.currentPid,
116+
);
117+
return {
118+
port,
119+
isListening: filteredPids.length > 0,
120+
pids: filteredPids,
121+
error: null,
122+
};
123+
} catch (error) {
124+
return {
125+
port,
126+
isListening: false,
127+
pids: [] as number[],
128+
error: errorMessage(error),
129+
};
130+
}
131+
}),
132+
);
133+
return { results };
134+
}
135+
136+
export async function stopLocalPorts(
137+
input: LocalProcessStopPortsInput,
138+
controls: LocalProcessControls = defaultLocalProcessControls,
139+
): Promise<LocalProcessStopPortsResult> {
140+
const results: Array<{ port: number; killedPids: number[]; errors: string[] }> = [];
141+
142+
for (const port of normalizePorts(input)) {
143+
const errors: string[] = [];
144+
let pids: readonly number[] = [];
145+
146+
try {
147+
pids = await controls.listListeningPids(port);
148+
} catch (error) {
149+
errors.push(`Failed to inspect port ${port}: ${errorMessage(error)}`);
150+
}
151+
152+
const killedPids: number[] = [];
153+
for (const pid of new Set(pids)) {
154+
if (!Number.isSafeInteger(pid) || pid <= 0) {
155+
continue;
156+
}
157+
if (pid === controls.currentPid) {
158+
errors.push(`Refusing to stop the current T3 Code process on port ${port}.`);
159+
continue;
160+
}
161+
try {
162+
await controls.killPid(pid);
163+
killedPids.push(pid);
164+
} catch (error) {
165+
const code =
166+
error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : "";
167+
if (code !== "ESRCH") {
168+
errors.push(`Failed to stop process ${pid} on port ${port}: ${errorMessage(error)}`);
169+
}
170+
}
171+
}
172+
173+
results.push({ port, killedPids, errors });
174+
}
175+
176+
return { results };
177+
}

0 commit comments

Comments
 (0)