-
Notifications
You must be signed in to change notification settings - Fork 2.2k
feat(web): detect agent-spawned local servers and surface in sidebar #2241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Marve10s
wants to merge
11
commits into
pingdotgg:main
Choose a base branch
from
Marve10s:feat/agent-server-status-overlay
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
7a639a9
feat(web): detect agent-spawned local servers and surface in sidebar
Marve10s a20bb58
fix(web): restore work-log turn scoping and widen URL extraction
Marve10s e4868ab
fix(web): drop dead "Agent ran command" status variant
Marve10s fcefe13
fix(web): break shell-stream loop that rewrote sidebar summary every …
Marve10s 4869630
restore fast mode for nightly gpt models
Marve10s 2893afe
fix(web): address agent server status review feedback
Marve10s f8a7b9e
fix(web): ignore stale port probe results
Marve10s 4a2aacb
fix(web): keep agent command status aligned across streams
Marve10s 87aad5c
fix(web): show probed ports without detected urls
Marve10s 872a84e
Merge upstream/main into feat/agent-server-status-overlay
Marve10s e022a8e
Merge upstream/main into feat/agent-server-status-overlay
Marve10s File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { | ||
| parseListeningPidList, | ||
| probeLocalPorts, | ||
| stopLocalPorts, | ||
| type LocalProcessControls, | ||
| } from "./localProcesses.ts"; | ||
|
|
||
| function makeControls(overrides: Partial<LocalProcessControls> = {}): LocalProcessControls { | ||
| return { | ||
| currentPid: 999, | ||
| listListeningPids: vi.fn(async () => []), | ||
| killPid: vi.fn(), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe("localProcesses", () => { | ||
| it("parses and deduplicates listening process ids", () => { | ||
| expect(parseListeningPidList("123\n456\n123 ignored\n")).toEqual([123, 456]); | ||
| }); | ||
|
|
||
| it("stops unique pids for unique ports", async () => { | ||
| const controls = makeControls({ | ||
| listListeningPids: vi.fn(async (port) => (port === 5173 ? [111, 222, 111] : [])), | ||
| killPid: vi.fn(), | ||
| }); | ||
|
|
||
| await expect(stopLocalPorts({ ports: [5173, 5173, 3000] }, controls)).resolves.toEqual({ | ||
| results: [ | ||
| { port: 5173, killedPids: [111, 222], errors: [] }, | ||
| { port: 3000, killedPids: [], errors: [] }, | ||
| ], | ||
| }); | ||
| expect(controls.killPid).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it("refuses to stop the current T3 Code process", async () => { | ||
| const controls = makeControls({ | ||
| currentPid: 111, | ||
| listListeningPids: vi.fn(async () => [111]), | ||
| killPid: vi.fn(), | ||
| }); | ||
|
|
||
| const result = await stopLocalPorts({ ports: [5173] }, controls); | ||
|
|
||
| expect(result.results[0]?.killedPids).toEqual([]); | ||
| expect(result.results[0]?.errors[0]).toContain("Refusing to stop"); | ||
| expect(controls.killPid).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("reports the current T3 Code process as listening when probing ports", async () => { | ||
| const controls = makeControls({ | ||
| currentPid: 111, | ||
| listListeningPids: vi.fn(async () => [111]), | ||
| }); | ||
|
|
||
| await expect(probeLocalPorts({ ports: [5173] }, controls)).resolves.toEqual({ | ||
| results: [{ port: 5173, isListening: true, pids: [111], error: null }], | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import type { | ||
| LocalProcessProbePortsInput, | ||
| LocalProcessProbePortsResult, | ||
| LocalProcessStopPortsInput, | ||
| LocalProcessStopPortsResult, | ||
| } from "@t3tools/contracts"; | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import * as Duration from "effect/Duration"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
|
|
||
| import { layer as ProcessRunnerLive, ProcessRunner } from "./processRunner.ts"; | ||
|
|
||
| const PORT_LOOKUP_TIMEOUT_MS = 2_000; | ||
| const PORT_LOOKUP_MAX_BUFFER_BYTES = 64 * 1024; | ||
|
|
||
| export interface LocalProcessControls { | ||
| readonly listListeningPids: (port: number) => Promise<readonly number[]>; | ||
| readonly killPid: (pid: number) => Promise<void> | void; | ||
| readonly currentPid: number; | ||
| } | ||
|
|
||
| export function parseListeningPidList(text: string): number[] { | ||
| const seen = new Set<number>(); | ||
| for (const token of text.split(/\s+/u)) { | ||
| if (!/^\d+$/u.test(token)) { | ||
| continue; | ||
| } | ||
| const pid = Number(token); | ||
| if (Number.isSafeInteger(pid) && pid > 0) { | ||
| seen.add(pid); | ||
| } | ||
| } | ||
| return [...seen]; | ||
| } | ||
|
|
||
| async function runLocalProcess( | ||
| command: string, | ||
| args: readonly string[], | ||
| ): Promise<{ stdout: string }> { | ||
| const result = await Effect.runPromise( | ||
| Effect.gen(function* () { | ||
| const processRunner = yield* ProcessRunner; | ||
| return yield* processRunner.run({ | ||
| command, | ||
| args, | ||
| maxOutputBytes: PORT_LOOKUP_MAX_BUFFER_BYTES, | ||
| outputMode: "truncate", | ||
| timeout: Duration.millis(PORT_LOOKUP_TIMEOUT_MS), | ||
| timeoutBehavior: "timedOutResult", | ||
| }); | ||
| }).pipe(Effect.provide(ProcessRunnerLive.pipe(Layer.provide(NodeServices.layer)))), | ||
| ); | ||
| if (result.timedOut) { | ||
| throw new Error(`${command} timed out after ${PORT_LOOKUP_TIMEOUT_MS}ms`); | ||
| } | ||
| return { stdout: result.stdout }; | ||
| } | ||
|
|
||
| function normalizePorts( | ||
| input: LocalProcessStopPortsInput | LocalProcessProbePortsInput, | ||
| maxCount: number, | ||
| ): number[] { | ||
| const seen = new Set<number>(); | ||
| for (const port of input.ports) { | ||
| if (Number.isInteger(port) && port >= 1 && port <= 65_535) { | ||
| seen.add(port); | ||
| } | ||
| } | ||
| return [...seen].slice(0, maxCount); | ||
| } | ||
|
|
||
| async function listListeningPidsWithLsof(port: number): Promise<number[]> { | ||
| const result = await runLocalProcess("lsof", ["-nP", "-ti", `TCP:${port}`, "-sTCP:LISTEN"]); | ||
| return parseListeningPidList(result.stdout); | ||
| } | ||
|
|
||
| async function listListeningPidsWithPowerShell(port: number): Promise<number[]> { | ||
| const command = [ | ||
| "Get-NetTCPConnection", | ||
| `-LocalPort ${port}`, | ||
| "-State Listen", | ||
| "-ErrorAction SilentlyContinue", | ||
| "| Select-Object -ExpandProperty OwningProcess -Unique", | ||
| ].join(" "); | ||
| const result = await runLocalProcess("powershell.exe", [ | ||
| "-NoProfile", | ||
| "-NonInteractive", | ||
| "-Command", | ||
| command, | ||
| ]); | ||
| return parseListeningPidList(result.stdout); | ||
| } | ||
|
|
||
| async function listListeningPids(port: number): Promise<number[]> { | ||
| if (process.platform === "win32") { | ||
| return listListeningPidsWithPowerShell(port); | ||
| } | ||
| return listListeningPidsWithLsof(port); | ||
| } | ||
|
|
||
| function killPid(pid: number): void { | ||
| process.kill(pid, "SIGTERM"); | ||
| } | ||
|
|
||
| export const defaultLocalProcessControls: LocalProcessControls = { | ||
| currentPid: process.pid, | ||
| listListeningPids, | ||
| killPid, | ||
| }; | ||
|
|
||
| function errorMessage(error: unknown): string { | ||
| return error instanceof Error ? error.message : String(error); | ||
| } | ||
|
|
||
| export async function probeLocalPorts( | ||
| input: LocalProcessProbePortsInput, | ||
| controls: LocalProcessControls = defaultLocalProcessControls, | ||
| ): Promise<LocalProcessProbePortsResult> { | ||
| const ports = normalizePorts(input, 32); | ||
| const results = await Promise.all( | ||
| ports.map(async (port) => { | ||
| try { | ||
| const pids = await controls.listListeningPids(port); | ||
| const filteredPids = [...new Set(pids)].filter( | ||
| (pid) => Number.isSafeInteger(pid) && pid > 0, | ||
| ); | ||
| return { | ||
| port, | ||
| isListening: filteredPids.length > 0, | ||
| pids: filteredPids, | ||
| error: null, | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| port, | ||
| isListening: false, | ||
| pids: [] as number[], | ||
| error: errorMessage(error), | ||
| }; | ||
| } | ||
| }), | ||
| ); | ||
| return { results }; | ||
| } | ||
|
|
||
| export async function stopLocalPorts( | ||
| input: LocalProcessStopPortsInput, | ||
| controls: LocalProcessControls = defaultLocalProcessControls, | ||
| ): Promise<LocalProcessStopPortsResult> { | ||
| const results: Array<{ port: number; killedPids: number[]; errors: string[] }> = []; | ||
|
|
||
| for (const port of normalizePorts(input, 16)) { | ||
| const errors: string[] = []; | ||
| let pids: readonly number[] = []; | ||
|
|
||
| try { | ||
| pids = await controls.listListeningPids(port); | ||
| } catch (error) { | ||
| errors.push(`Failed to inspect port ${port}: ${errorMessage(error)}`); | ||
| } | ||
|
|
||
| const killedPids: number[] = []; | ||
| for (const pid of new Set(pids)) { | ||
| if (!Number.isSafeInteger(pid) || pid <= 0) { | ||
| continue; | ||
| } | ||
| if (pid === controls.currentPid) { | ||
| errors.push(`Refusing to stop the current T3 Code process on port ${port}.`); | ||
| continue; | ||
| } | ||
| try { | ||
| await controls.killPid(pid); | ||
| killedPids.push(pid); | ||
| } catch (error) { | ||
| const code = | ||
| error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : ""; | ||
| if (code !== "ESRCH") { | ||
| errors.push(`Failed to stop process ${pid} on port ${port}: ${errorMessage(error)}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| results.push({ port, killedPids, errors }); | ||
| } | ||
|
|
||
| return { results }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.