diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 5f26536b835..fd4c54ddf8b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -40,6 +40,7 @@ import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSes import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; +import { DetectedServersIngress } from "../src/detectedServers/Layers/DetectedServersIngress.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, @@ -284,6 +285,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge( + Layer.succeed(DetectedServersIngress, { + trackAgentCommand: () => Effect.succeed({ feed: () => {}, end: () => {} }), + trackPty: () => Effect.succeed({ feed: () => {}, feedCommand: () => {}, end: () => {} }), + }), + ), ); const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); const providerLayer = useRealCodex diff --git a/apps/server/integration/detectedServersCodex.integration.test.ts b/apps/server/integration/detectedServersCodex.integration.test.ts new file mode 100644 index 00000000000..2691d196a7c --- /dev/null +++ b/apps/server/integration/detectedServersCodex.integration.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { Effect, Layer, Duration } from "effect"; +import { + DetectedServerRegistryLive, + DetectedServerRegistry, +} from "../src/detectedServers/Services/DetectedServerRegistry.ts"; +import { SocketProbeLive } from "../src/detectedServers/Layers/SocketProbeLive.ts"; +import { LivenessHeartbeatLive } from "../src/detectedServers/Layers/LivenessHeartbeat.ts"; +import { + DetectedServersIngress, + DetectedServersIngressLive, +} from "../src/detectedServers/Layers/DetectedServersIngress.ts"; + +describe("DetectedServers / Codex synthetic", () => { + it("transitions predicted → candidate on Vite-shaped outputDelta", async () => { + const depsLayer = Layer.mergeAll( + DetectedServerRegistryLive, + SocketProbeLive, + LivenessHeartbeatLive, + ); + const testLayer = Layer.mergeAll( + depsLayer, + DetectedServersIngressLive.pipe(Layer.provide(depsLayer)), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackAgentCommand( + { + threadId: "thread-1", + turnId: "turn-1", + itemId: "item-1", + argv: ["vite"], + cwd: "/tmp", + }, + "codex", + undefined, + ); + tracker.feed(" ➜ Local: http://localhost:5173/\n"); + // Poll until the sniffer callback registers the candidate (bounded wait). + let current = yield* registry.getCurrent("thread-1"); + for (let attempt = 0; attempt < 50 && current[0]?.status !== "candidate"; attempt += 1) { + yield* Effect.sleep(Duration.millis(50)); + current = yield* registry.getCurrent("thread-1"); + } + expect(current).toHaveLength(1); + expect(current[0]!.status).toBe("candidate"); + expect(current[0]!.url).toBe("http://localhost:5173/"); + expect(current[0]!.framework).toBe("vite"); + }).pipe(Effect.provide(testLayer)), + ); + }); +}); diff --git a/apps/server/integration/detectedServersPty.integration.test.ts b/apps/server/integration/detectedServersPty.integration.test.ts new file mode 100644 index 00000000000..8694017bbf3 --- /dev/null +++ b/apps/server/integration/detectedServersPty.integration.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { Effect, Layer, Duration } from "effect"; +import { spawn } from "node:child_process"; +import { + DetectedServerRegistryLive, + DetectedServerRegistry, +} from "../src/detectedServers/Services/DetectedServerRegistry.ts"; +import { SocketProbeLive } from "../src/detectedServers/Layers/SocketProbeLive.ts"; +import { LivenessHeartbeatLive } from "../src/detectedServers/Layers/LivenessHeartbeat.ts"; +import { + DetectedServersIngress, + DetectedServersIngressLive, +} from "../src/detectedServers/Layers/DetectedServersIngress.ts"; + +describe("DetectedServers / PTY real server", () => { + it.skipIf(process.platform === "win32")( + "transitions predicted → candidate → confirmed → live for a real Node http server", + async () => { + const child = spawn( + process.execPath, + [ + "-e", + `const http = require("node:http"); const s = http.createServer((req, res) => res.end("ok")); s.listen(0, "127.0.0.1", () => { console.log("Server listening on port " + s.address().port); });`, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + try { + const depsLayer = Layer.mergeAll( + DetectedServerRegistryLive, + SocketProbeLive, + LivenessHeartbeatLive, + ); + const testLayer = Layer.mergeAll( + depsLayer, + DetectedServersIngressLive.pipe(Layer.provide(depsLayer)), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + + const tracker = yield* ingress.trackPty( + { + threadId: "thread-1", + terminalId: "terminal-1", + pid: child.pid!, + argv: ["npm", "run", "dev"], + cwd: "/tmp", + }, + { scripts: { dev: "vite" } }, + ); + + child.stdout!.on("data", (d: Buffer) => tracker.feed(d.toString("utf8"))); + + // Poll for live with timeout (up to ~15 seconds) + let server = null; + for (let i = 0; i < 150; i += 1) { + yield* Effect.sleep(Duration.millis(100)); + const current = yield* registry.getCurrent("thread-1"); + if (current[0]?.status === "live") { + server = current[0]; + break; + } + } + expect(server).not.toBeNull(); + expect(server!.status).toBe("live"); + expect(server!.port).toBeGreaterThan(0); + expect(server!.terminalId).toBe("terminal-1"); + }).pipe(Effect.provide(testLayer)), + ); + } finally { + child.kill(); + } + }, + 20_000, + ); +}); diff --git a/apps/server/package.json b/apps/server/package.json index 151c885f5e1..c4c80e3e4ef 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,6 +34,7 @@ "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", + "pidtree": "^0.6.0", "sharp": "^0.34.5" }, "devDependencies": { diff --git a/apps/server/src/detectedServers/ArgvHinter.test.ts b/apps/server/src/detectedServers/ArgvHinter.test.ts new file mode 100644 index 00000000000..44deddb0294 --- /dev/null +++ b/apps/server/src/detectedServers/ArgvHinter.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { hintFromArgv } from "./Layers/ArgvHinter.ts"; + +describe("ArgvHinter.hintFromArgv", () => { + const cases: ReadonlyArray<{ + name: string; + argv: string[]; + expected: { framework: string; isLikelyServer: boolean }; + }> = [ + { name: "vite", argv: ["vite"], expected: { framework: "vite", isLikelyServer: true } }, + { + name: "next dev", + argv: ["next", "dev"], + expected: { framework: "next", isLikelyServer: true }, + }, + { + name: "nuxt dev", + argv: ["nuxt", "dev"], + expected: { framework: "nuxt", isLikelyServer: true }, + }, + { + name: "astro dev", + argv: ["astro", "dev"], + expected: { framework: "astro", isLikelyServer: true }, + }, + { + name: "remix dev", + argv: ["remix", "dev"], + expected: { framework: "remix", isLikelyServer: true }, + }, + { + name: "wrangler dev", + argv: ["wrangler", "dev"], + expected: { framework: "wrangler", isLikelyServer: true }, + }, + { + name: "vitest --ui", + argv: ["vitest", "--ui"], + expected: { framework: "vitest-ui", isLikelyServer: true }, + }, + { + name: "storybook dev", + argv: ["storybook", "dev"], + expected: { framework: "storybook", isLikelyServer: true }, + }, + { + name: "vite build", + argv: ["vite", "build"], + expected: { framework: "vite", isLikelyServer: false }, + }, + { + name: "vitest run", + argv: ["vitest", "run"], + expected: { framework: "unknown", isLikelyServer: false }, + }, + { name: "tsc", argv: ["tsc"], expected: { framework: "unknown", isLikelyServer: false } }, + { name: "eslint", argv: ["eslint"], expected: { framework: "unknown", isLikelyServer: false } }, + { + name: "unknown serve", + argv: ["foo", "serve"], + expected: { framework: "unknown", isLikelyServer: true }, + }, + ]; + + for (const c of cases) { + it(`hints ${c.name}`, () => { + const got = hintFromArgv(c.argv, undefined); + expect(got).toEqual(c.expected); + }); + } + + it("re-scans package.json scripts.dev for indirect invocations", () => { + const got = hintFromArgv(["npm", "run", "dev"], { scripts: { dev: "vite" } }); + expect(got).toEqual({ framework: "vite", isLikelyServer: true }); + }); + + it("treats npm run build as build, not server", () => { + const got = hintFromArgv(["npm", "run", "build"], { scripts: { build: "vite build" } }); + expect(got).toEqual({ framework: "vite", isLikelyServer: false }); + }); + + // Regression tests: prefix-match false positives + it("does NOT match next for a binary named snextflix", () => { + const got = hintFromArgv(["snextflix"], undefined); + expect(got).toEqual({ framework: "unknown", isLikelyServer: false }); + }); + + it("does NOT match remix for a binary named myremix", () => { + const got = hintFromArgv(["myremix"], undefined); + expect(got).toEqual({ framework: "unknown", isLikelyServer: false }); + }); + + it("matches vite when invoked via a full node_modules path", () => { + const got = hintFromArgv(["./node_modules/.bin/vite"], undefined); + expect(got).toEqual({ framework: "vite", isLikelyServer: true }); + }); +}); diff --git a/apps/server/src/detectedServers/DetectedServersIngress.test.ts b/apps/server/src/detectedServers/DetectedServersIngress.test.ts new file mode 100644 index 00000000000..1f1468dfddd --- /dev/null +++ b/apps/server/src/detectedServers/DetectedServersIngress.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import { + DetectedServerRegistry, + DetectedServerRegistryLive, +} from "./Services/DetectedServerRegistry.ts"; +import { + DetectedServersIngress, + DetectedServersIngressLive, +} from "./Layers/DetectedServersIngress.ts"; +import { LivenessHeartbeat } from "./Layers/LivenessHeartbeat.ts"; +import { SocketProbe, type ProbeResult } from "./Layers/SocketProbe.ts"; + +const stubProbe = (rows: ReadonlyArray = []) => + Layer.succeed(SocketProbe, { probe: () => Effect.succeed(rows) }); + +const stubHeartbeat = (ok: boolean) => + Layer.succeed(LivenessHeartbeat, { check: () => Effect.succeed(ok) }); + +const buildLayer = (probe: Layer.Layer, heartbeat: Layer.Layer) => { + const deps = Layer.mergeAll(DetectedServerRegistryLive, probe, heartbeat); + return Layer.mergeAll(deps, DetectedServersIngressLive.pipe(Layer.provide(deps))); +}; + +describe("DetectedServersIngress", () => { + it("trackAgentCommand returns a noop tracker when isLikelyServer is false", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackAgentCommand( + { + threadId: "t", + turnId: "u", + itemId: "i", + argv: ["ls", "-la"], + cwd: "/tmp", + }, + "codex", + undefined, + ); + tracker.feed("hello\n"); + tracker.end("success"); + yield* Effect.sleep(10); + const servers = yield* registry.getCurrent("t"); + expect(servers).toHaveLength(0); + }).pipe(Effect.provide(buildLayer(stubProbe(), stubHeartbeat(false)))), + ); + }); + + it("trackPty registers predicted, then candidate when the sniffer sees a URL", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { + threadId: "t-pty", + terminalId: "term-1", + pid: 12345, + argv: ["bun", "run", "dev"], + cwd: "/repo", + }, + { scripts: { dev: "vite" } }, + ); + const initial = yield* registry.getCurrent("t-pty"); + expect(initial).toHaveLength(1); + expect(initial[0]!.status).toBe("predicted"); + expect(initial[0]!.framework).toBe("vite"); + expect(initial[0]!.terminalId).toBe("term-1"); + + tracker.feed(" VITE v5.0.0 ready in 432 ms\n ➜ Local: http://localhost:5173/\n"); + yield* Effect.sleep(20); + + const updated = yield* registry.getCurrent("t-pty"); + expect(updated[0]!.status === "candidate" || updated[0]!.status === "live").toBe(true); + expect(updated[0]!.port).toBe(5173); + tracker.end(0); + }).pipe(Effect.provide(buildLayer(stubProbe(), stubHeartbeat(false)))), + ); + }); + + it("trackPty transitions toward live when SocketProbe returns a matching row and heartbeat agrees", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { + threadId: "t-live", + terminalId: "term-2", + pid: process.pid, + argv: ["bun", "run", "dev"], + cwd: "/repo", + }, + { scripts: { dev: "vite" } }, + ); + // Seed sniffedPort so the probe loop's `matching` lookup is deterministic. + tracker.feed(" ➜ Local: http://localhost:5173/\n"); + + // Poll up to 5s for the probe loop to land a "live" update. + let observed: string = "predicted"; + for (let i = 0; i < 50; i += 1) { + yield* Effect.sleep(100); + const servers = yield* registry.getCurrent("t-live"); + if (servers[0]?.status === "live") { + observed = "live"; + break; + } + } + expect(observed).toBe("live"); + const final = yield* registry.getCurrent("t-live"); + expect(final[0]!.port).toBe(5173); + tracker.end(0); + }).pipe( + Effect.provide( + buildLayer( + stubProbe([{ pid: process.pid, port: 5173, host: "127.0.0.1" }]), + stubHeartbeat(true), + ), + ), + ), + ); + }); + + it("prefers a heartbeat-alive candidate over the first listening port", async () => { + // Simulates: dev server binds both port 3000 (no HTTP) and port 5733 (alive). + // The probe loop should pick 5733 via heartbeat rather than the first port. + const heartbeat = Layer.succeed(LivenessHeartbeat, { + check: (url: string) => Effect.succeed(url.includes(":5733")), + }); + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { + threadId: "t-pick", + terminalId: "term-pick", + pid: 12345, + argv: ["bun", "run", "dev"], + cwd: "/repo", + }, + { scripts: { dev: "next dev" } }, + ); + // Seed the sniffer with port 3000 (e.g., Next.js prints localhost:3000 + // even when port 3000 is in use, then settles on 5733). + tracker.feed(" - Local: http://localhost:3000\n"); + let observed = "predicted"; + let observedPort: number | undefined; + for (let i = 0; i < 50; i += 1) { + yield* Effect.sleep(100); + const servers = yield* registry.getCurrent("t-pick"); + if (servers[0]?.status === "live") { + observed = "live"; + observedPort = servers[0].port; + break; + } + } + expect(observed).toBe("live"); + expect(observedPort).toBe(5733); + tracker.end(0); + }).pipe( + Effect.provide( + buildLayer( + stubProbe([ + { pid: 12345, port: 3000, host: "127.0.0.1" }, + { pid: 12345, port: 5733, host: "127.0.0.1" }, + ]), + heartbeat, + ), + ), + ), + ); + }); + + it("end() interrupts the probe fiber so no further SocketProbe calls fire", async () => { + let probeCalls = 0; + const countingProbe = Layer.succeed(SocketProbe, { + probe: () => + Effect.sync(() => { + probeCalls += 1; + return [] as ProbeResult[]; + }), + }); + + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const tracker = yield* ingress.trackPty( + { + threadId: "t-end", + terminalId: "term-3", + pid: process.pid, + argv: ["bun", "run", "dev"], + cwd: "/repo", + }, + { scripts: { dev: "vite" } }, + ); + // Let one or two probe cycles run. + yield* Effect.sleep(400); + const callsBeforeEnd = probeCalls; + expect(callsBeforeEnd).toBeGreaterThan(0); + tracker.end(0); + // Wait through several poll intervals — no further calls should land. + yield* Effect.sleep(800); + expect(probeCalls).toBe(callsBeforeEnd); + }).pipe(Effect.provide(buildLayer(countingProbe, stubHeartbeat(false)))), + ); + }); +}); diff --git a/apps/server/src/detectedServers/Handlers.test.ts b/apps/server/src/detectedServers/Handlers.test.ts new file mode 100644 index 00000000000..0b266114b24 --- /dev/null +++ b/apps/server/src/detectedServers/Handlers.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; +import { DateTime, Effect, Layer } from "effect"; +import type { DetectedServer } from "@ryco/contracts"; +import { handleDetectedServerOpenInBrowser, handleDetectedServerStop } from "./Handlers.ts"; +import { + DetectedServerRegistry, + type DetectedServerRegistryShape, +} from "./Services/DetectedServerRegistry.ts"; +import { Registry } from "./Layers/Registry.ts"; +import type { TerminalManagerShape } from "../terminal/Services/Manager.ts"; +import type { OpenShape } from "../open.ts"; + +// Each test gets a fresh registry instance so writes from earlier tests do +// not leak through the module-level DetectedServerRegistryLive layer. +const freshRegistryLayer = (): Layer.Layer => { + const r = new Registry(); + return Layer.succeed(DetectedServerRegistry, { + registerOrUpdate: (input) => Effect.sync(() => r.registerOrUpdate(input)), + publishLog: (id, data) => Effect.sync(() => r.publishLog(id, data)), + remove: (id) => Effect.sync(() => r.remove(id)), + subscribe: (tid, listener) => Effect.sync(() => r.subscribe(tid, listener)), + getCurrent: (tid) => Effect.sync(() => r.getCurrent(tid)), + findById: (id) => Effect.sync(() => r.findById(id)), + } satisfies DetectedServerRegistryShape); +}; + +const now = DateTime.fromDateUnsafe(new Date()); + +const baseServer: DetectedServer = { + id: "server-1", + threadId: "thread-1", + source: "pty", + framework: "vite", + status: "live", + url: "http://localhost:5173", + port: 5173, + host: "127.0.0.1", + pid: 9999, + terminalId: "terminal-1", + argv: ["bun", "run", "dev"], + cwd: "/repo", + startedAt: now, + lastSeenAt: now, +}; + +const seedRegistry = (server: DetectedServer) => + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + const patch: Record = { + framework: server.framework, + status: server.status, + }; + if (server.url !== undefined) patch.url = server.url; + if (server.port !== undefined) patch.port = server.port; + if (server.host !== undefined) patch.host = server.host; + if (server.pid !== undefined) patch.pid = server.pid; + if (server.terminalId !== undefined) patch.terminalId = server.terminalId; + if (server.argv !== undefined) patch.argv = server.argv; + if (server.cwd !== undefined) patch.cwd = server.cwd; + yield* registry.registerOrUpdate({ + threadId: server.threadId, + source: server.source, + identityKey: `${server.threadId}::${server.source}::${server.id}`, + patch: patch as Parameters[0]["patch"], + }); + return registry; + }); + +const fakeTerminalManager = (close: ReturnType): TerminalManagerShape => + ({ + close, + }) as unknown as TerminalManagerShape; + +const fakeOpen = (openBrowser: ReturnType): OpenShape => + ({ + openBrowser, + }) as unknown as OpenShape; + +describe("detectedServers handlers", () => { + describe("handleDetectedServerStop", () => { + it("returns not-stoppable when the server id is unknown", async () => { + const close = vi.fn(() => Effect.void); + const result = await Effect.runPromise( + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + return yield* handleDetectedServerStop(registry, fakeTerminalManager(close), "missing"); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(result).toEqual({ kind: "not-stoppable", hint: "interrupt-turn" }); + expect(close).not.toHaveBeenCalled(); + }); + + it("calls TerminalManager.close and returns stopped for source=pty + terminalId", async () => { + const close = vi.fn(() => Effect.void); + const result = await Effect.runPromise( + Effect.gen(function* () { + const registry = yield* seedRegistry(baseServer); + const servers = yield* registry.getCurrent("thread-1"); + const created = servers[0]!; + return yield* handleDetectedServerStop(registry, fakeTerminalManager(close), created.id); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(result).toEqual({ kind: "stopped" }); + expect(close).toHaveBeenCalledTimes(1); + const firstArg = ( + close.mock.calls[0] as unknown as [{ threadId: string; terminalId: string }] + )[0]; + expect(firstArg).toEqual({ threadId: "thread-1", terminalId: "terminal-1" }); + }); + + it("returns not-stoppable for non-pty servers", async () => { + const close = vi.fn(() => Effect.void); + const codexServer: DetectedServer = { + ...baseServer, + source: "codex", + terminalId: undefined, + }; + const result = await Effect.runPromise( + Effect.gen(function* () { + const registry = yield* seedRegistry(codexServer); + const servers = yield* registry.getCurrent("thread-1"); + return yield* handleDetectedServerStop( + registry, + fakeTerminalManager(close), + servers[0]!.id, + ); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(result).toEqual({ kind: "not-stoppable", hint: "interrupt-turn" }); + expect(close).not.toHaveBeenCalled(); + }); + }); + + describe("handleDetectedServerOpenInBrowser", () => { + it("opens the server URL in the browser when one exists", async () => { + const openBrowser = vi.fn(() => Effect.void); + const result = await Effect.runPromise( + Effect.gen(function* () { + const registry = yield* seedRegistry(baseServer); + const servers = yield* registry.getCurrent("thread-1"); + return yield* handleDetectedServerOpenInBrowser( + registry, + fakeOpen(openBrowser), + servers[0]!.id, + ); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(result).toEqual({ ok: true }); + expect(openBrowser).toHaveBeenCalledWith("http://localhost:5173"); + }); + + it("returns ok:false and does not open when the server has no url", async () => { + const openBrowser = vi.fn(() => Effect.void); + const noUrlServer: DetectedServer = { ...baseServer, url: undefined }; + const result = await Effect.runPromise( + Effect.gen(function* () { + const registry = yield* seedRegistry(noUrlServer); + const servers = yield* registry.getCurrent("thread-1"); + return yield* handleDetectedServerOpenInBrowser( + registry, + fakeOpen(openBrowser), + servers[0]!.id, + ); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(result).toEqual({ ok: false }); + expect(openBrowser).not.toHaveBeenCalled(); + }); + }); + + describe("DetectedServerRegistry subscribe + snapshot semantics", () => { + it("subscriber receives a registered event when a new server is registered", async () => { + const events: unknown[] = []; + await Effect.runPromise( + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + const unsub = yield* registry.subscribe("thread-x", (e) => events.push(e)); + yield* registry.registerOrUpdate({ + threadId: "thread-x", + source: "pty", + identityKey: "key-1", + patch: { framework: "vite", status: "predicted", terminalId: "term-1" }, + }); + unsub(); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(events).toHaveLength(1); + expect((events[0] as { type: string }).type).toBe("registered"); + }); + + it("snapshot replay (getCurrent) returns pre-existing servers so the WS handler can synthesize registered events", async () => { + const initial = await Effect.runPromise( + Effect.gen(function* () { + yield* seedRegistry(baseServer); + const registry = yield* DetectedServerRegistry; + return yield* registry.getCurrent("thread-1"); + }).pipe(Effect.provide(freshRegistryLayer())), + ); + expect(initial).toHaveLength(1); + expect(initial[0]!.framework).toBe("vite"); + expect(initial[0]!.terminalId).toBe("terminal-1"); + }); + }); +}); diff --git a/apps/server/src/detectedServers/Handlers.ts b/apps/server/src/detectedServers/Handlers.ts new file mode 100644 index 00000000000..872a99a5d4e --- /dev/null +++ b/apps/server/src/detectedServers/Handlers.ts @@ -0,0 +1,41 @@ +import { Effect } from "effect"; +import type { OpenShape } from "../open.ts"; +import type { TerminalManagerShape } from "../terminal/Services/Manager.ts"; +import type { DetectedServerRegistryShape } from "./Services/DetectedServerRegistry.ts"; + +export type StopResult = + | { readonly kind: "stopped" } + | { readonly kind: "not-stoppable"; readonly hint: "interrupt-turn" }; + +export const handleDetectedServerStop = ( + registry: DetectedServerRegistryShape, + terminalManager: TerminalManagerShape, + serverId: string, +): Effect.Effect => + Effect.gen(function* () { + const server = yield* registry.findById(serverId); + if (!server) { + return { kind: "not-stoppable", hint: "interrupt-turn" } as const; + } + if (server.source === "pty" && server.terminalId) { + yield* terminalManager + .close({ threadId: server.threadId, terminalId: server.terminalId }) + .pipe(Effect.ignore({ log: true })); + return { kind: "stopped" } as const; + } + return { kind: "not-stoppable", hint: "interrupt-turn" } as const; + }); + +export const handleDetectedServerOpenInBrowser = ( + registry: DetectedServerRegistryShape, + open: OpenShape, + serverId: string, +): Effect.Effect<{ readonly ok: boolean }> => + Effect.gen(function* () { + const server = yield* registry.findById(serverId); + if (!server?.url) { + return { ok: false } as const; + } + yield* open.openBrowser(server.url).pipe(Effect.ignore({ log: true })); + return { ok: true } as const; + }); diff --git a/apps/server/src/detectedServers/Layers/ArgvHinter.ts b/apps/server/src/detectedServers/Layers/ArgvHinter.ts new file mode 100644 index 00000000000..e51b0ac864c --- /dev/null +++ b/apps/server/src/detectedServers/Layers/ArgvHinter.ts @@ -0,0 +1,112 @@ +import type { ServerFramework } from "@ryco/contracts"; + +export interface ArgvHint { + framework: ServerFramework; + isLikelyServer: boolean; +} + +export interface PackageJsonShape { + scripts?: Record; +} + +const DENY_TOKENS = new Set([ + "build", + "test", + "tsc", + "eslint", + "prettier", + "playwright", + "typecheck", + "lint", + "fmt", +]); + +const SERVER_TRIGGER_TOKENS = new Set(["dev", "serve", "start", "watch"]); + +const FRAMEWORK_TOKEN_MAP: ReadonlyArray = [ + ["vite", "vite"], + ["next", "next"], + ["nuxt", "nuxt"], + ["nuxi", "nuxt"], + ["astro", "astro"], + ["remix", "remix"], + ["wrangler", "wrangler"], + ["storybook", "storybook"], + ["webpack-dev-server", "webpack"], +]; + +const PACKAGE_RUNNERS = new Set(["npm", "pnpm", "yarn", "bun"]); + +/** Strip path components from a token, returning only the filename portion. */ +const basename = (token: string): string => token.split(/[\\/]/).pop() ?? token; + +export const hintFromArgv = ( + argv: ReadonlyArray, + pkg: PackageJsonShape | undefined, +): ArgvHint => { + const tokens = argv.map((t) => t.toLowerCase()); + + // Indirect invocation: run + // One-level expansion only — passing `undefined` for pkg in the recursive + // call prevents infinite loops on scripts that reference each other. + if ( + tokens.length >= 3 && + PACKAGE_RUNNERS.has(tokens[0]!) && + tokens[1] === "run" && + pkg?.scripts?.[tokens[2]!] + ) { + const inner = pkg.scripts[tokens[2]!]!.split(/\s+/).filter(Boolean); + return hintFromArgv(inner, undefined); + } + + // Shortcut: dev / serve / start / watch (no explicit "run" keyword) + // One-level expansion only — passing `undefined` for pkg prevents infinite loops. + if ( + tokens.length >= 2 && + PACKAGE_RUNNERS.has(tokens[0]!) && + SERVER_TRIGGER_TOKENS.has(tokens[1]!) + ) { + if (pkg?.scripts?.[tokens[1]!]) { + const inner = pkg.scripts[tokens[1]!]!.split(/\s+/).filter(Boolean); + return hintFromArgv(inner, undefined); + } + } + + // Special-case: vitest --ui (UI mode is a server; vitest run is not) + if (tokens[0] === "vitest" && tokens.includes("--ui")) { + return { framework: "vitest-ui", isLikelyServer: true }; + } + + // Framework token match. + // The first argv token is treated as a file path — strip any directory prefix + // so that `./node_modules/.bin/vite` matches `vite`, but `snextflix` does NOT + // match `next`. Only the first three non-flag tokens (binary + subcommand + + // subsubcommand) are considered; the framework name must be an exact match + // (with optional JS extension on the head token). + const head = tokens[0] ? basename(tokens[0]) : ""; + const positional = tokens.slice(0, 3).filter((t) => !t.startsWith("-")); + + for (const [tok, fw] of FRAMEWORK_TOKEN_MAP) { + const hasFrameworkToken = + head === tok || + head === `${tok}.js` || + head === `${tok}.cjs` || + head === `${tok}.mjs` || + positional.includes(tok); + + if (hasFrameworkToken) { + const hasDeny = tokens.some((t) => DENY_TOKENS.has(t)); + if (hasDeny) return { framework: fw, isLikelyServer: false }; + return { framework: fw, isLikelyServer: true }; + } + } + + // Generic server trigger tokens + const hasServerTrigger = tokens.some((t) => SERVER_TRIGGER_TOKENS.has(t)); + const hasDeny = tokens.some((t) => DENY_TOKENS.has(t)); + if (hasServerTrigger && !hasDeny) { + return { framework: "unknown", isLikelyServer: true }; + } + + return { framework: "unknown", isLikelyServer: false }; +}; diff --git a/apps/server/src/detectedServers/Layers/DetectedServersIngress.ts b/apps/server/src/detectedServers/Layers/DetectedServersIngress.ts new file mode 100644 index 00000000000..878cc0ff1fd --- /dev/null +++ b/apps/server/src/detectedServers/Layers/DetectedServersIngress.ts @@ -0,0 +1,317 @@ +import { Context, DateTime, Duration, Effect, Fiber, Layer, Schema } from "effect"; +import pidtree from "pidtree"; +import { DetectedServerRegistry } from "../Services/DetectedServerRegistry.ts"; +import { SocketProbe } from "./SocketProbe.ts"; +import { LivenessHeartbeat } from "./LivenessHeartbeat.ts"; +import { StdoutSniffer } from "./StdoutSniffer.ts"; +import { hintFromArgv, type PackageJsonShape } from "./ArgvHinter.ts"; + +class PidtreeError extends Schema.TaggedErrorClass("s3/detectedServers/PidtreeError")( + "PidtreeError", + { pid: Schema.Int }, +) {} + +const DEBUGGER_PORTS = new Set([9229, 9230]); + +const argvHasInspect = (argv: ReadonlyArray): number[] => { + const out: number[] = []; + for (const t of argv) { + const m = t.match(/--inspect(?:-brk|-wait)?=(?:[^:]*:)?(\d+)/); + if (m) out.push(Number.parseInt(m[1]!, 10)); + } + return out; +}; + +export interface CodexCommandSource { + threadId: string; + turnId: string; + itemId: string; + argv: ReadonlyArray; + cwd: string; +} + +export interface PtyCommandSource { + threadId: string; + terminalId: string; + pid: number; + argv: ReadonlyArray; + cwd: string; +} + +export interface CommandTracker { + feed: (chunk: string) => void; + end: (result: "success" | "error") => void; +} + +export interface PtyTracker { + feed: (chunk: string) => void; + feedCommand: (argv: ReadonlyArray, cwd: string) => void; + end: (exitCode: number | null) => void; +} + +export interface DetectedServersIngressShape { + readonly trackAgentCommand: ( + source: CodexCommandSource, + sourceKind: "codex" | "acp", + pkg: PackageJsonShape | undefined, + ) => Effect.Effect; + readonly trackPty: ( + source: PtyCommandSource, + pkg: PackageJsonShape | undefined, + ) => Effect.Effect; +} + +export class DetectedServersIngress extends Context.Service< + DetectedServersIngress, + DetectedServersIngressShape +>()("s3/detectedServers/Layers/DetectedServersIngress") {} + +const noopTracker = (): CommandTracker => ({ feed: () => {}, end: () => {} }); + +export const DetectedServersIngressLive = Layer.effect( + DetectedServersIngress, + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + const probe = yield* SocketProbe; + const heartbeat = yield* LivenessHeartbeat; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const trackAgentCommand = ( + source: CodexCommandSource, + sourceKind: "codex" | "acp", + pkg: PackageJsonShape | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const hint = hintFromArgv(source.argv, pkg); + if (!hint.isLikelyServer) return noopTracker(); + + const identityKey = `${source.threadId}::${sourceKind}::${source.turnId}::${source.itemId}`; + const server = yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: sourceKind, + identityKey, + patch: { + framework: hint.framework, + status: "predicted", + argv: source.argv, + cwd: source.cwd, + }, + }); + + const sniffer = new StdoutSniffer(); + const unsubCandidate = sniffer.onCandidate((c) => { + runFork( + registry.registerOrUpdate({ + threadId: source.threadId, + source: sourceKind, + identityKey, + patch: { + status: "candidate", + framework: c.framework !== "unknown" ? c.framework : hint.framework, + url: c.url, + port: c.port, + host: c.host, + }, + }), + ); + }); + + return { + feed: (chunk: string) => { + sniffer.feed(chunk); + runFork(registry.publishLog(server.id, chunk)); + }, + end: (result: "success" | "error") => { + unsubCandidate(); + runFork( + registry.registerOrUpdate({ + threadId: source.threadId, + source: sourceKind, + identityKey, + patch: { + status: "exited", + exitedAt: DateTime.fromDateUnsafe(new Date()), + exitReason: result === "success" ? "stopped" : "crashed", + }, + }), + ); + }, + }; + }); + + const trackPty = ( + source: PtyCommandSource, + pkg: PackageJsonShape | undefined, + ): Effect.Effect => + Effect.gen(function* () { + interface SubTracker { + serverId: string; + identityKey: string; + sniffer: StdoutSniffer; + unsubCandidate: () => void; + probeFiber: Fiber.Fiber; + } + + const subTrackers: SubTracker[] = []; + let commandSeq = 0; + + const startSubTracker = ( + argv: ReadonlyArray, + cwd: string, + ): Effect.Effect => + Effect.gen(function* () { + const hint = hintFromArgv(argv, pkg); + if (!hint.isLikelyServer) return null; + + commandSeq += 1; + const identityKey = `${source.threadId}::pty::${source.pid}::${commandSeq}`; + const server = yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + framework: hint.framework, + status: "predicted", + pid: source.pid, + terminalId: source.terminalId, + argv, + cwd, + }, + }); + + const sniffer = new StdoutSniffer(); + let sniffedPort: number | null = null; + const unsubCandidate = sniffer.onCandidate((c) => { + sniffedPort = c.port; + runFork( + registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: "candidate", + framework: c.framework !== "unknown" ? c.framework : hint.framework, + url: c.url, + port: c.port, + host: c.host, + }, + }), + ); + }); + + const denyPorts = new Set([...DEBUGGER_PORTS, ...argvHasInspect(argv)]); + + const probeFiber = runFork( + Effect.gen(function* () { + let liveSeenAt: Date | null = null; + let lastConfirmedPort: number | null = null; + while (true) { + const pids = yield* Effect.tryPromise({ + try: () => pidtree(source.pid, { root: true }), + catch: () => new PidtreeError({ pid: source.pid }), + }).pipe(Effect.orElseSucceed(() => [source.pid] as number[])); + const rows = yield* probe.probe(pids); + const candidates = rows.filter((r) => !denyPorts.has(r.port)); + if (candidates.length > 0 && !liveSeenAt) { + // Probe heartbeat on each candidate, preferring the sniffed + // port. The first one that responds wins — this avoids + // sticking on a transient port that briefly listened during + // startup but isn't the real server. + const ordered = sniffedPort + ? [ + ...candidates.filter((r) => r.port === sniffedPort), + ...candidates.filter((r) => r.port !== sniffedPort), + ] + : candidates; + let liveCandidate: (typeof candidates)[number] | undefined; + for (const c of ordered) { + const ok = yield* heartbeat.check(`http://localhost:${c.port}`); + if (ok) { + liveCandidate = c; + break; + } + } + if (liveCandidate) { + liveSeenAt = new Date(); + yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: "live", + port: liveCandidate.port, + host: liveCandidate.host, + url: `http://localhost:${liveCandidate.port}`, + liveAt: DateTime.fromDateUnsafe(liveSeenAt), + }, + }); + } else { + const preferred = ordered[0]!; + if (lastConfirmedPort !== preferred.port) { + lastConfirmedPort = preferred.port; + yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: "confirmed", + port: preferred.port, + host: preferred.host, + }, + }); + } + } + } + yield* Effect.sleep(liveSeenAt ? Duration.seconds(2) : Duration.millis(250)); + } + }), + ); + + return { serverId: server.id, identityKey, sniffer, unsubCandidate, probeFiber }; + }); + + const initial = yield* startSubTracker(source.argv, source.cwd); + if (initial) subTrackers.push(initial); + + return { + feed: (chunk: string) => { + for (const sub of subTrackers) { + sub.sniffer.feed(chunk); + runFork(registry.publishLog(sub.serverId, chunk)); + } + }, + feedCommand: (argv: ReadonlyArray, cwd: string) => { + runFork( + Effect.gen(function* () { + const sub = yield* startSubTracker(argv, cwd); + if (sub) subTrackers.push(sub); + }), + ); + }, + end: (exitCode: number | null) => { + const status = exitCode === 0 || exitCode === null ? "exited" : "crashed"; + const exitReason: "stopped" | "crashed" = status === "exited" ? "stopped" : "crashed"; + for (const sub of subTrackers) { + sub.unsubCandidate(); + runFork(Fiber.interrupt(sub.probeFiber).pipe(Effect.ignore)); + runFork( + registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey: sub.identityKey, + patch: { + status, + exitedAt: DateTime.fromDateUnsafe(new Date()), + exitReason, + }, + }), + ); + } + }, + }; + }); + + return { trackAgentCommand, trackPty }; + }), +); diff --git a/apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts b/apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts new file mode 100644 index 00000000000..a3da17b508a --- /dev/null +++ b/apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts @@ -0,0 +1,48 @@ +/** + * LivenessHeartbeat - HTTP liveness probe service. + * + * Performs HEAD requests with a 500ms timeout to check if a server is up. + * Any HTTP response (2xx/3xx/4xx/5xx) counts as success. + * + * @module LivenessHeartbeat + */ +import { Effect, Context, Layer, Schema } from "effect"; + +class HeartbeatError extends Schema.TaggedErrorClass( + "s3/detectedServers/HeartbeatError", +)("HeartbeatError", { url: Schema.String }) {} + +/** + * LivenessHeartbeatShape - Service API for HTTP liveness checks. + */ +export interface LivenessHeartbeatShape { + /** + * Single liveness check. Returns true if any HTTP response was received + * (any 2xx/3xx/4xx/5xx counts as "the server is up"). + */ + readonly check: (url: string) => Effect.Effect; +} + +/** + * LivenessHeartbeat - Service tag for HTTP liveness probe integration. + */ +export class LivenessHeartbeat extends Context.Service()( + "s3/detectedServers/Layers/LivenessHeartbeat", +) {} + +const checkImpl = (url: string): Effect.Effect => + Effect.tryPromise({ + try: () => fetch(url, { method: "HEAD", signal: AbortSignal.timeout(500) }), + catch: () => new HeartbeatError({ url }), + }).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + +/** + * LivenessHeartbeatLive - Layer providing the default implementation. + */ +export const LivenessHeartbeatLive: Layer.Layer = Layer.succeed( + LivenessHeartbeat, + { check: checkImpl }, +); diff --git a/apps/server/src/detectedServers/Layers/Registry.ts b/apps/server/src/detectedServers/Layers/Registry.ts new file mode 100644 index 00000000000..a3988d42d16 --- /dev/null +++ b/apps/server/src/detectedServers/Layers/Registry.ts @@ -0,0 +1,196 @@ +import { randomUUID } from "node:crypto"; +import { DateTime } from "effect"; +import type { + DetectedServer, + DetectedServerEvent, + ExitReason, + ServerFramework, + ServerSource, + ServerStatus, +} from "@ryco/contracts"; + +const ALLOWED_TRANSITIONS: Record> = { + predicted: ["candidate", "confirmed", "exited", "crashed"], + candidate: ["confirmed", "live", "exited", "crashed"], + confirmed: ["live", "exited", "crashed"], + live: ["restarting", "exited", "crashed"], + restarting: ["live", "exited", "crashed"], + exited: [], + crashed: [], +}; + +export interface RegistryPatch { + framework?: ServerFramework; + status?: ServerStatus; + url?: string; + port?: number; + host?: string; + pid?: number; + terminalId?: string; + argv?: ReadonlyArray; + cwd?: string; + liveAt?: DateTime.Utc; + lastSeenAt?: DateTime.Utc; + exitedAt?: DateTime.Utc; + exitReason?: ExitReason; +} + +export interface RegistryRegisterInput { + threadId: string; + source: ServerSource; + identityKey: string; + patch: RegistryPatch; +} + +type Listener = (e: DetectedServerEvent) => void; + +const cloneServer = (s: DetectedServer): DetectedServer => + s.argv ? { ...s, argv: [...s.argv] } : { ...s }; + +export class Registry { + private byThread = new Map>(); + private idByIdentity = new Map(); + private listeners = new Map>(); + + subscribe(threadId: string, listener: Listener): () => void { + const set = this.listeners.get(threadId) ?? new Set(); + set.add(listener); + this.listeners.set(threadId, set); + return () => { + const cur = this.listeners.get(threadId); + cur?.delete(listener); + }; + } + + getCurrent(threadId: string): DetectedServer[] { + const m = this.byThread.get(threadId); + return m ? [...m.values()].map(cloneServer) : []; + } + + findById(serverId: string): DetectedServer | undefined { + for (const m of this.byThread.values()) { + const s = m.get(serverId); + if (s) return cloneServer(s); + } + return undefined; + } + + registerOrUpdate(input: RegistryRegisterInput): DetectedServer { + const existingId = this.idByIdentity.get(input.identityKey); + if (existingId) return this.updateExisting(input, existingId); + return this.registerNew(input); + } + + publishLog(serverId: string, data: string): void { + const threadId = this.findThreadOf(serverId); + if (!threadId) return; + this.publish(threadId, { + type: "log", + threadId, + serverId, + data, + createdAt: new Date().toISOString(), + }); + } + + remove(serverId: string): void { + const threadId = this.findThreadOf(serverId); + if (!threadId) return; + const m = this.byThread.get(threadId); + const server = m?.get(serverId); + if (!server || !m) return; + m.delete(serverId); + this.idByIdentity.forEach((id, key) => { + if (id === serverId) this.idByIdentity.delete(key); + }); + this.publish(threadId, { + type: "removed", + threadId, + serverId, + createdAt: new Date().toISOString(), + }); + } + + private registerNew(input: RegistryRegisterInput): DetectedServer { + const id = randomUUID(); + const now = DateTime.fromDateUnsafe(new Date()); + const status = input.patch.status ?? "predicted"; + const server: DetectedServer = { + id, + threadId: input.threadId, + source: input.source, + framework: input.patch.framework ?? "unknown", + status, + url: input.patch.url, + port: input.patch.port, + host: input.patch.host, + pid: input.patch.pid, + terminalId: input.patch.terminalId, + argv: input.patch.argv, + cwd: input.patch.cwd, + startedAt: now, + liveAt: input.patch.liveAt, + lastSeenAt: now, + exitedAt: input.patch.exitedAt, + exitReason: input.patch.exitReason, + }; + const m = this.byThread.get(input.threadId) ?? new Map(); + m.set(id, server); + this.byThread.set(input.threadId, m); + this.idByIdentity.set(input.identityKey, id); + this.publish(input.threadId, { + type: "registered", + threadId: input.threadId, + server, + createdAt: new Date().toISOString(), + }); + return server; + } + + private updateExisting(input: RegistryRegisterInput, serverId: string): DetectedServer { + const m = this.byThread.get(input.threadId); + const cur = m?.get(serverId); + if (!cur || !m) throw new Error(`Registry inconsistency: missing server ${serverId}`); + + if (input.patch.status && input.patch.status !== cur.status) { + const legal = ALLOWED_TRANSITIONS[cur.status]; + if (!legal.includes(input.patch.status)) { + throw new Error(`illegal transition ${cur.status} → ${input.patch.status} for ${serverId}`); + } + } + + const next: DetectedServer = { + ...cur, + ...input.patch, + lastSeenAt: input.patch.lastSeenAt ?? DateTime.fromDateUnsafe(new Date()), + }; + m.set(serverId, next); + this.publish(input.threadId, { + type: "updated", + threadId: input.threadId, + serverId, + patch: input.patch, + createdAt: new Date().toISOString(), + }); + return next; + } + + private findThreadOf(serverId: string): string | undefined { + for (const [threadId, m] of this.byThread) { + if (m.has(serverId)) return threadId; + } + return undefined; + } + + private publish(threadId: string, event: DetectedServerEvent): void { + const set = this.listeners.get(threadId); + if (!set) return; + for (const l of set) { + try { + l(event); + } catch (err) { + console.error("DetectedServerRegistry listener threw", { threadId, err }); + } + } + } +} diff --git a/apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts b/apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts new file mode 100644 index 00000000000..0408868407c --- /dev/null +++ b/apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts @@ -0,0 +1,58 @@ +import { Effect, Layer, Schema } from "effect"; +import { spawn } from "node:child_process"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; + +class SocketProbeError extends Schema.TaggedErrorClass( + "s3/detectedServers/SocketProbeError", +)("SocketProbeError", { stage: Schema.String }) {} + +export const parseLsofOutput = (text: string): ProbeResult[] => { + if (!text.trim()) return []; + const lines = text.split("\n").slice(1); + const out: ProbeResult[] = []; + for (const line of lines) { + if (!line.includes("(LISTEN)")) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 9) continue; + const pid = Number.parseInt(parts[1]!, 10); + const nameField = parts.slice(8, parts.length - 1).join(" "); + let host = "0.0.0.0"; + let port = -1; + const ipv6 = nameField.match(/^\[([^\]]+)\]:(\d+)/); + const ipv4 = nameField.match(/^([^:]+):(\d+)/); + if (ipv6) { + host = ipv6[1]!; + port = Number.parseInt(ipv6[2]!, 10); + } else if (ipv4) { + host = ipv4[1] === "*" ? "0.0.0.0" : ipv4[1]!; + port = Number.parseInt(ipv4[2]!, 10); + } + if (port > 0) out.push({ pid, port, host }); + } + return out; +}; + +const runLsof = (pids: ReadonlyArray): Effect.Effect => { + if (pids.length === 0) return Effect.succeed(""); + return Effect.tryPromise({ + try: () => + new Promise((resolve) => { + const child = spawn("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], { + stdio: ["ignore", "pipe", "ignore"], + }); + let buf = ""; + child.stdout.on("data", (d: Buffer) => (buf += d.toString("utf8"))); + child.on("error", () => resolve("")); + child.on("close", () => resolve(buf)); + }), + catch: () => new SocketProbeError({ stage: "lsof" }), + }).pipe(Effect.orElseSucceed(() => "")); +}; + +const probeImpl = (pids: ReadonlyArray): Effect.Effect> => + Effect.gen(function* () { + const out = yield* runLsof(pids); + return parseLsofOutput(out); + }); + +export const SocketProbeDarwinLive = Layer.succeed(SocketProbe, { probe: probeImpl }); diff --git a/apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts b/apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts new file mode 100644 index 00000000000..483b43a0f96 --- /dev/null +++ b/apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts @@ -0,0 +1,117 @@ +import { Effect, Layer, Schema } from "effect"; +import { readFile, readdir, readlink } from "node:fs/promises"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; + +class SocketProbeError extends Schema.TaggedErrorClass( + "s3/detectedServers/SocketProbeError", +)("SocketProbeError", { stage: Schema.String }) {} + +export interface ProcTcpRow { + inode: number; + port: number; + host: string; + state: "LISTEN" | string; +} + +const STATE_MAP: Record = { "0A": "LISTEN" }; + +const hexToIpv4 = (hex: string): string => { + // /proc reverses byte order: "0100007F" → "127.0.0.1" + const bytes = [hex.slice(6, 8), hex.slice(4, 6), hex.slice(2, 4), hex.slice(0, 2)]; + return bytes.map((b) => Number.parseInt(b, 16)).join("."); +}; + +const hexToIpv6 = (hex: string): string => { + if (hex === "00000000000000000000000000000000") return "::"; + const groups: string[] = []; + for (let i = 0; i < 8; i += 1) { + const start = i * 4; + groups.push(hex.slice(start, start + 4).toLowerCase()); + } + return groups.join(":"); +}; + +export const parseProcTcpRows = (text: string): ProcTcpRow[] => { + const lines = text + .split("\n") + .slice(1) + .filter((l) => l.trim().length > 0); + return lines.map((line) => { + const parts = line.trim().split(/\s+/); + const [hostHex, portHex] = parts[1]!.split(":"); + const state = STATE_MAP[parts[3]!] ?? parts[3]!; + return { + inode: Number.parseInt(parts[9]!, 10), + port: Number.parseInt(portHex!, 16), + host: hexToIpv4(hostHex!), + state, + }; + }); +}; + +export const parseProcTcp6Rows = (text: string): ProcTcpRow[] => { + const lines = text + .split("\n") + .slice(1) + .filter((l) => l.trim().length > 0); + return lines.map((line) => { + const parts = line.trim().split(/\s+/); + const [hostHex, portHex] = parts[1]!.split(":"); + const state = STATE_MAP[parts[3]!] ?? parts[3]!; + return { + inode: Number.parseInt(parts[9]!, 10), + port: Number.parseInt(portHex!, 16), + host: hostHex === "00000000000000000000000000000000" ? "::" : hexToIpv6(hostHex!), + state, + }; + }); +}; + +const inodesForPid = (pid: number): Effect.Effect> => + Effect.tryPromise({ + try: async () => { + const fdDir = `/proc/${pid}/fd`; + const entries = await readdir(fdDir); + const inodes = new Set(); + await Promise.all( + entries.map(async (e) => { + try { + const target = await readlink(`${fdDir}/${e}`); + const m = target.match(/^socket:\[(\d+)\]$/); + if (m) inodes.add(Number.parseInt(m[1]!, 10)); + } catch { + // fd may have closed between readdir and readlink — ignore + } + }), + ); + return inodes; + }, + catch: () => new SocketProbeError({ stage: "inodes" }), + }).pipe(Effect.orElseSucceed(() => new Set())); + +const probeImpl = (pids: ReadonlyArray): Effect.Effect> => + Effect.gen(function* () { + const pidInodes = yield* Effect.all( + pids.map((pid) => Effect.map(inodesForPid(pid), (inodes) => ({ pid, inodes }))), + ); + const inodeToPid = new Map(); + for (const { pid, inodes } of pidInodes) { + for (const inode of inodes) inodeToPid.set(inode, pid); + } + + const tcpText = yield* Effect.tryPromise({ + try: () => readFile("/proc/net/tcp", "utf8"), + catch: () => new SocketProbeError({ stage: "tcp" }), + }).pipe(Effect.orElseSucceed(() => "")); + const tcp6Text = yield* Effect.tryPromise({ + try: () => readFile("/proc/net/tcp6", "utf8"), + catch: () => new SocketProbeError({ stage: "tcp6" }), + }).pipe(Effect.orElseSucceed(() => "")); + + const rows = [...parseProcTcpRows(tcpText), ...parseProcTcp6Rows(tcp6Text)]; + return rows + .filter((r) => r.state === "LISTEN" && inodeToPid.has(r.inode)) + .map((r) => ({ pid: inodeToPid.get(r.inode)!, port: r.port, host: r.host })); + }); + +export const SocketProbeLinuxLive = Layer.succeed(SocketProbe, { probe: probeImpl }); diff --git a/apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts b/apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts new file mode 100644 index 00000000000..357bfa25008 --- /dev/null +++ b/apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts @@ -0,0 +1,59 @@ +import { Effect, Layer, Schema } from "effect"; +import { spawn } from "node:child_process"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; + +class SocketProbeError extends Schema.TaggedErrorClass( + "s3/detectedServers/SocketProbeError", +)("SocketProbeError", { stage: Schema.String }) {} + +export const parseNetstatOutput = (text: string): ProbeResult[] => { + if (!text.trim()) return []; + const out: ProbeResult[] = []; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line.startsWith("TCP")) continue; + if (!line.includes("LISTENING")) continue; + const parts = line.split(/\s+/); + if (parts.length < 5) continue; + const local = parts[1]!; + const pid = Number.parseInt(parts[4]!, 10); + let host: string; + let port: number; + if (local.startsWith("[")) { + const m = local.match(/^\[([^\]]+)\]:(\d+)$/); + if (!m) continue; + host = m[1]!; + port = Number.parseInt(m[2]!, 10); + } else { + const idx = local.lastIndexOf(":"); + if (idx < 0) continue; + host = local.slice(0, idx); + port = Number.parseInt(local.slice(idx + 1), 10); + } + if (Number.isFinite(pid) && Number.isFinite(port)) out.push({ pid, port, host }); + } + return out; +}; + +const runNetstat = (): Effect.Effect => + Effect.tryPromise({ + try: () => + new Promise((resolve) => { + const child = spawn("netstat", ["-ano"], { stdio: ["ignore", "pipe", "ignore"] }); + let buf = ""; + child.stdout.on("data", (d: Buffer) => (buf += d.toString("utf8"))); + child.on("error", () => resolve("")); + child.on("close", () => resolve(buf)); + }), + catch: () => new SocketProbeError({ stage: "netstat" }), + }).pipe(Effect.orElseSucceed(() => "")); + +const probeImpl = (pids: ReadonlyArray): Effect.Effect> => + Effect.gen(function* () { + if (pids.length === 0) return []; + const out = yield* runNetstat(); + const pidSet = new Set(pids); + return parseNetstatOutput(out).filter((r) => pidSet.has(r.pid)); + }); + +export const SocketProbeWindowsLive = Layer.succeed(SocketProbe, { probe: probeImpl }); diff --git a/apps/server/src/detectedServers/Layers/SocketProbe.ts b/apps/server/src/detectedServers/Layers/SocketProbe.ts new file mode 100644 index 00000000000..82a7962645a --- /dev/null +++ b/apps/server/src/detectedServers/Layers/SocketProbe.ts @@ -0,0 +1,36 @@ +/** + * SocketProbe - Socket probing service contract. + * + * Defines the interface for probing LISTEN sockets owned by processes + * without binding to a specific OS implementation. + * + * @module SocketProbe + */ +import { Effect, Context } from "effect"; + +/** + * ProbeResult - A single socket probe result. + */ +export interface ProbeResult { + pid: number; + port: number; + host: string; +} + +/** + * SocketProbeShape - Service API for probing LISTEN sockets. + */ +export interface SocketProbeShape { + /** + * Probe for LISTEN sockets owned by any of the given pids. + * Returns rows of (pid, port, host) — empty when unavailable. + */ + readonly probe: (pids: ReadonlyArray) => Effect.Effect>; +} + +/** + * SocketProbe - Service tag for socket probing integration. + */ +export class SocketProbe extends Context.Service()( + "s3/detectedServers/Layers/SocketProbe", +) {} diff --git a/apps/server/src/detectedServers/Layers/SocketProbeLive.ts b/apps/server/src/detectedServers/Layers/SocketProbeLive.ts new file mode 100644 index 00000000000..23c3f04afd5 --- /dev/null +++ b/apps/server/src/detectedServers/Layers/SocketProbeLive.ts @@ -0,0 +1,32 @@ +/** + * SocketProbeLive - OS-selecting Layer for SocketProbe. + * + * Separated from SocketProbe.ts to avoid circular imports: the OS adapter + * files (SocketProbe.Linux.ts, etc.) import SocketProbe from SocketProbe.ts, + * so SocketProbe.ts must not import them back. + * + * @module SocketProbeLive + */ +import { Effect, Layer } from "effect"; +import { platform } from "node:os"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; +import { SocketProbeLinuxLive } from "./SocketProbe.Linux.ts"; +import { SocketProbeDarwinLive } from "./SocketProbe.Darwin.ts"; +import { SocketProbeWindowsLive } from "./SocketProbe.Windows.ts"; + +const NoopProbeLive = Layer.succeed(SocketProbe, { + probe: () => Effect.succeed([] as ReadonlyArray), +}); + +export const SocketProbeLive: Layer.Layer = (() => { + switch (platform()) { + case "linux": + return SocketProbeLinuxLive; + case "darwin": + return SocketProbeDarwinLive; + case "win32": + return SocketProbeWindowsLive; + default: + return NoopProbeLive; + } +})(); diff --git a/apps/server/src/detectedServers/Layers/StdoutSniffer.ts b/apps/server/src/detectedServers/Layers/StdoutSniffer.ts new file mode 100644 index 00000000000..86ba56556bb --- /dev/null +++ b/apps/server/src/detectedServers/Layers/StdoutSniffer.ts @@ -0,0 +1,110 @@ +import type { ServerFramework } from "@ryco/contracts"; + +export interface UrlCandidate { + url: string; + port: number; + host: string; + framework: ServerFramework; +} + +const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]/g; +const URL_REGEX_GENERIC = + /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::(\d+))?(?:\/\S*)?/i; + +interface FrameworkPattern { + framework: ServerFramework; + lineHint: RegExp; +} + +const FRAMEWORK_PATTERNS: ReadonlyArray = [ + { framework: "vite", lineHint: /\bVITE\b/i }, + { framework: "next", lineHint: /Next\.js|^\s*-?\s*Local:\s+http.*localhost:3000/i }, + { framework: "nuxt", lineHint: /Nuxt\s+\d/i }, + { framework: "astro", lineHint: /astro\s+v\d/i }, + { framework: "remix", lineHint: /remix dev|serving HTTP on/i }, + { framework: "wrangler", lineHint: /wrangler|\[mf:inf\] Ready on/i }, + { framework: "webpack", lineHint: /\[webpack-dev-server\] (?:Loopback|Project is running)/i }, +]; + +const PORT_ONLY_REGEX = /\b(?:listening|server (?:listening|running))\b[^\d]*?\b(\d{2,5})\b/i; + +export class StdoutSniffer { + private fragment = ""; + private listeners = new Set<(c: UrlCandidate) => void>(); + private contextLines: { framework: ServerFramework | null; lines: string[] } = { + framework: null, + lines: [], + }; + + onCandidate(cb: (c: UrlCandidate) => void): () => void { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + feed(chunk: string): void { + if (chunk.length === 0) return; + const combined = this.fragment + chunk; + const parts = combined.split("\n"); + this.fragment = parts.pop() ?? ""; + for (const raw of parts) this.consumeLine(raw); + } + + private consumeLine(raw: string): void { + const line = raw.replace(ANSI_REGEX, "").replace(/\s+/g, " ").trim(); + if (!line) return; + + // Detect framework hint from any recent line; carry it forward + for (const pattern of FRAMEWORK_PATTERNS) { + if (pattern.lineHint.test(line)) { + this.contextLines.framework = pattern.framework; + break; + } + } + + // Try to extract URL on this line + const urlMatch = line.match(URL_REGEX_GENERIC); + if (urlMatch) { + const url = urlMatch[0]; + const host = this.extractHost(url); + const port = this.extractPort(url); + if (port !== null) { + this.emit({ + url, + port, + host, + framework: this.contextLines.framework ?? "unknown", + }); + return; + } + } + + // Fallback: port-only Express-style line + const portMatch = line.match(PORT_ONLY_REGEX); + if (portMatch) { + const port = Number.parseInt(portMatch[1]!, 10); + this.emit({ + url: `http://localhost:${port}`, + port, + host: "localhost", + framework: this.contextLines.framework ?? "express", + }); + } + } + + private extractHost(url: string): string { + const m = url.match(/https?:\/\/(\[[^\]]+\]|[^/:]+)/i); + return m?.[1] ?? "localhost"; + } + + private extractPort(url: string): number | null { + const m = url.match(/:(\d+)(?:\/|$)/); + if (m) return Number.parseInt(m[1]!, 10); + if (url.startsWith("https://")) return 443; + if (url.startsWith("http://")) return 80; + return null; + } + + private emit(c: UrlCandidate): void { + for (const l of this.listeners) l(c); + } +} diff --git a/apps/server/src/detectedServers/LivenessHeartbeat.test.ts b/apps/server/src/detectedServers/LivenessHeartbeat.test.ts new file mode 100644 index 00000000000..4b475165fb5 --- /dev/null +++ b/apps/server/src/detectedServers/LivenessHeartbeat.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; +import { LivenessHeartbeatLive, LivenessHeartbeat } from "./Layers/LivenessHeartbeat.ts"; + +const runCheck = (url: string) => + Effect.runPromise( + Effect.gen(function* () { + const heartbeat = yield* LivenessHeartbeat; + return yield* heartbeat.check(url); + }).pipe(Effect.provide(LivenessHeartbeatLive)), + ); + +describe("LivenessHeartbeat", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns true on a 200 response", async () => { + (globalThis.fetch as unknown as ReturnType).mockResolvedValue( + new Response(null, { status: 200 }), + ); + expect(await runCheck("http://localhost:5173")).toBe(true); + }); + + it("returns true on a 500 response (any HTTP response counts)", async () => { + (globalThis.fetch as unknown as ReturnType).mockResolvedValue( + new Response(null, { status: 500 }), + ); + expect(await runCheck("http://localhost:5173")).toBe(true); + }); + + it("returns false on AbortError (timeout)", async () => { + (globalThis.fetch as unknown as ReturnType).mockRejectedValue( + Object.assign(new Error("aborted"), { name: "AbortError" }), + ); + expect(await runCheck("http://localhost:5173")).toBe(false); + }); + + it("returns false on a generic network error", async () => { + (globalThis.fetch as unknown as ReturnType).mockRejectedValue( + new Error("ECONNREFUSED"), + ); + expect(await runCheck("http://localhost:5173")).toBe(false); + }); +}); diff --git a/apps/server/src/detectedServers/PtyCommandTracker.test.ts b/apps/server/src/detectedServers/PtyCommandTracker.test.ts new file mode 100644 index 00000000000..89a98888174 --- /dev/null +++ b/apps/server/src/detectedServers/PtyCommandTracker.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import { + DetectedServerRegistry, + DetectedServerRegistryLive, +} from "./Services/DetectedServerRegistry.ts"; +import { + DetectedServersIngress, + DetectedServersIngressLive, +} from "./Layers/DetectedServersIngress.ts"; +import { LivenessHeartbeat } from "./Layers/LivenessHeartbeat.ts"; +import { SocketProbe } from "./Layers/SocketProbe.ts"; +import { PtyInputLineBuffer, tokenizeShellLine } from "./PtyInputLineBuffer.ts"; + +const stubProbe = Layer.succeed(SocketProbe, { probe: () => Effect.succeed([]) }); +const stubHeartbeat = Layer.succeed(LivenessHeartbeat, { check: () => Effect.succeed(false) }); + +const testLayer = (() => { + const deps = Layer.mergeAll(DetectedServerRegistryLive, stubProbe, stubHeartbeat); + return Layer.mergeAll(deps, DetectedServersIngressLive.pipe(Layer.provide(deps))); +})(); + +describe("PtyInputLineBuffer", () => { + it("emits line on Enter (LF)", () => { + const lines: string[] = []; + const buf = new PtyInputLineBuffer((l) => lines.push(l)); + buf.write("bun run dev\n"); + expect(lines).toEqual(["bun run dev"]); + }); + + it("emits line on CR", () => { + const lines: string[] = []; + const buf = new PtyInputLineBuffer((l) => lines.push(l)); + buf.write("bun run dev\r"); + expect(lines).toEqual(["bun run dev"]); + }); + + it("respects backspace before Enter", () => { + const lines: string[] = []; + const buf = new PtyInputLineBuffer((l) => lines.push(l)); + // Type "bxn", backspace twice, type "un run dev", Enter + buf.write("bxn\x7f\x7fun run dev\r"); + expect(lines).toEqual(["bun run dev"]); + }); + + it("ignores empty / whitespace-only lines", () => { + const lines: string[] = []; + const buf = new PtyInputLineBuffer((l) => lines.push(l)); + buf.write("\r\n \r\n"); + expect(lines).toEqual([]); + }); + + it("Ctrl-C clears buffer without emitting", () => { + const lines: string[] = []; + const buf = new PtyInputLineBuffer((l) => lines.push(l)); + buf.write("rm -rf /\x03ls\r"); + expect(lines).toEqual(["ls"]); + }); +}); + +describe("tokenizeShellLine", () => { + it("splits on whitespace", () => { + expect(tokenizeShellLine(" bun run dev ")).toEqual(["bun", "run", "dev"]); + }); + + it("returns empty for blank input", () => { + expect(tokenizeShellLine(" ")).toEqual([]); + }); +}); + +describe("PtyTracker.feedCommand", () => { + it("registers a server when a likely-server line is typed", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { + threadId: "thread-1", + terminalId: "terminal-1", + pid: 99999, + argv: ["/bin/bash"], + cwd: "/tmp", + }, + { scripts: { dev: "vite" } }, + ); + + const buf = new PtyInputLineBuffer((line) => { + const argv = tokenizeShellLine(line); + if (argv.length > 0) tracker.feedCommand(argv, "/tmp"); + }); + + // Type "bun run dx", backspace once → "bun run d", then "ev\r" → submits "bun run dev" + buf.write("bun run dx\x7fev\r"); + + // feedCommand schedules registry work via runFork; yield once for it to land. + yield* Effect.sleep(10); + + const servers = yield* registry.getCurrent("thread-1"); + expect(servers).toHaveLength(1); + expect(servers[0]!.framework).toBe("vite"); + expect(servers[0]!.argv).toEqual(["bun", "run", "dev"]); + expect(servers[0]!.terminalId).toBe("terminal-1"); + }).pipe(Effect.provide(testLayer)), + ); + }); + + it("does not register for empty / whitespace-only typed lines", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { + threadId: "thread-2", + terminalId: "terminal-2", + pid: 99998, + argv: ["/bin/bash"], + cwd: "/tmp", + }, + undefined, + ); + const buf = new PtyInputLineBuffer((line) => { + const argv = tokenizeShellLine(line); + if (argv.length > 0) tracker.feedCommand(argv, "/tmp"); + }); + buf.write("\r\n \r\n"); + yield* Effect.sleep(10); + const servers = yield* registry.getCurrent("thread-2"); + expect(servers).toHaveLength(0); + }).pipe(Effect.provide(testLayer)), + ); + }); + + it("ignores non-server commands", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { + threadId: "thread-3", + terminalId: "terminal-3", + pid: 99997, + argv: ["/bin/bash"], + cwd: "/tmp", + }, + undefined, + ); + tracker.feedCommand(["ls", "-la"], "/tmp"); + tracker.feedCommand(["bun", "run", "test"], "/tmp"); + yield* Effect.sleep(10); + const servers = yield* registry.getCurrent("thread-3"); + expect(servers).toHaveLength(0); + }).pipe(Effect.provide(testLayer)), + ); + }); +}); diff --git a/apps/server/src/detectedServers/PtyInputLineBuffer.ts b/apps/server/src/detectedServers/PtyInputLineBuffer.ts new file mode 100644 index 00000000000..2510b80f9a8 --- /dev/null +++ b/apps/server/src/detectedServers/PtyInputLineBuffer.ts @@ -0,0 +1,49 @@ +/** + * Tracks user input written into a PTY and emits whole command lines on Enter. + * + * V1 limitations: cursor keys / arrow editing leak escape characters into the + * buffer (the bracket and trailing letter survive the printable filter), and + * we do not interpret &&, |, or ; chains — the whole line is treated as a + * single command. ArgvHinter classifies that garbage as "unknown" and skips + * registration, which is acceptable. + */ + +const BACKSPACE_DEL = "\x7f"; +const BACKSPACE_BS = "\b"; +const CTRL_C = "\x03"; +const CTRL_U = "\x15"; + +export class PtyInputLineBuffer { + private buffer = ""; + private readonly onLine: (line: string) => void; + + constructor(onLine: (line: string) => void) { + this.onLine = onLine; + } + + write(chunk: string): void { + for (const ch of chunk) { + if (ch === "\r" || ch === "\n") { + this.flush(); + } else if (ch === BACKSPACE_DEL || ch === BACKSPACE_BS) { + this.buffer = this.buffer.slice(0, -1); + } else if (ch === CTRL_C || ch === CTRL_U) { + this.buffer = ""; + } else if (ch >= " ") { + this.buffer += ch; + } else if (ch === "\t") { + this.buffer += " "; + } + } + } + + private flush(): void { + const line = this.buffer; + this.buffer = ""; + if (line.trim().length === 0) return; + this.onLine(line); + } +} + +export const tokenizeShellLine = (line: string): string[] => + line.trim().split(/\s+/).filter(Boolean); diff --git a/apps/server/src/detectedServers/Registry.test.ts b/apps/server/src/detectedServers/Registry.test.ts new file mode 100644 index 00000000000..6a5119ba98d --- /dev/null +++ b/apps/server/src/detectedServers/Registry.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { Registry } from "./Layers/Registry.ts"; +import type { DetectedServerEvent } from "@ryco/contracts"; + +const collectEvents = (registry: Registry) => { + const events: DetectedServerEvent[] = []; + registry.subscribe("thread-1", (e) => events.push(e)); + return events; +}; + +describe("Registry", () => { + it("registers a predicted server and emits a registered event", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { + framework: "vite", + status: "predicted", + pid: 42, + port: 5173, + argv: ["vite"], + cwd: "/work", + }, + }); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("registered"); + if (events[0]!.type === "registered") { + expect(events[0]!.server.status).toBe("predicted"); + expect(events[0]!.server.framework).toBe("vite"); + } + }); + + it("emits updated on subsequent transitions", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "predicted", pid: 42, port: 5173 }, + }); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { status: "candidate", url: "http://localhost:5173/" }, + }); + expect(events[1]!.type).toBe("updated"); + }); + + it("rejects illegal transition (live → predicted)", () => { + const r = new Registry(); + collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "live", pid: 42, port: 5173 }, + }); + expect(() => + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { status: "predicted" }, + }), + ).toThrow(/illegal transition/i); + }); + + it("treats same identityKey as restart, not new server", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "live", pid: 42, port: 5173 }, + }); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { status: "restarting" }, + }); + expect(r.getCurrent("thread-1").length).toBe(1); + expect(events.filter((e) => e.type === "registered").length).toBe(1); + }); + + it("getCurrent returns servers for a thread only", () => { + const r = new Registry(); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "predicted", pid: 42, port: 5173 }, + }); + r.registerOrUpdate({ + threadId: "thread-2", + source: "pty", + identityKey: "thread-2::99::3000", + patch: { framework: "next", status: "predicted", pid: 99, port: 3000 }, + }); + expect(r.getCurrent("thread-1").length).toBe(1); + expect(r.getCurrent("thread-2").length).toBe(1); + }); + + it("publishLog emits log events to subscribers of the matching thread", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "predicted", pid: 42, port: 5173 }, + }); + const serverId = r.getCurrent("thread-1")[0]!.id; + r.publishLog(serverId, "hello\n"); + const log = events.find((e) => e.type === "log"); + expect(log).toBeDefined(); + if (log?.type === "log") { + expect(log.data).toBe("hello\n"); + } + }); +}); diff --git a/apps/server/src/detectedServers/Services/DetectedServerRegistry.ts b/apps/server/src/detectedServers/Services/DetectedServerRegistry.ts new file mode 100644 index 00000000000..8423af41097 --- /dev/null +++ b/apps/server/src/detectedServers/Services/DetectedServerRegistry.ts @@ -0,0 +1,35 @@ +import { Effect, Context, Layer } from "effect"; +import type { DetectedServer, DetectedServerEvent } from "@ryco/contracts"; +import { Registry, type RegistryRegisterInput } from "../Layers/Registry.ts"; + +export interface DetectedServerRegistryShape { + readonly registerOrUpdate: (input: RegistryRegisterInput) => Effect.Effect; + readonly publishLog: (serverId: string, data: string) => Effect.Effect; + readonly remove: (serverId: string) => Effect.Effect; + readonly subscribe: ( + threadId: string, + listener: (e: DetectedServerEvent) => void, + ) => Effect.Effect<() => void>; + readonly getCurrent: (threadId: string) => Effect.Effect>; + readonly findById: (serverId: string) => Effect.Effect; +} + +export class DetectedServerRegistry extends Context.Service< + DetectedServerRegistry, + DetectedServerRegistryShape +>()("s3/detectedServers/Services/DetectedServerRegistry") {} + +export const DetectedServerRegistryLive = Layer.succeed( + DetectedServerRegistry, + (() => { + const r = new Registry(); + return { + registerOrUpdate: (input) => Effect.sync(() => r.registerOrUpdate(input)), + publishLog: (id, data) => Effect.sync(() => r.publishLog(id, data)), + remove: (id) => Effect.sync(() => r.remove(id)), + subscribe: (tid, listener) => Effect.sync(() => r.subscribe(tid, listener)), + getCurrent: (tid) => Effect.sync(() => r.getCurrent(tid)), + findById: (id) => Effect.sync(() => r.findById(id)), + }; + })(), +); diff --git a/apps/server/src/detectedServers/SocketProbe.Darwin.test.ts b/apps/server/src/detectedServers/SocketProbe.Darwin.test.ts new file mode 100644 index 00000000000..1fcc5000b87 --- /dev/null +++ b/apps/server/src/detectedServers/SocketProbe.Darwin.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { parseLsofOutput } from "./Layers/SocketProbe.Darwin.ts"; + +describe("SocketProbe.Darwin.parseLsofOutput", () => { + it("parses lsof TCP LISTEN rows", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 alice 23u IPv4 0xabc12345abc1234 0t0 TCP 127.0.0.1:5173 (LISTEN) +node 12345 alice 24u IPv6 0xabc12345abc1235 0t0 TCP [::1]:5173 (LISTEN) +node 99999 alice 25u IPv4 0xabc12345abc1236 0t0 TCP *:3000 (LISTEN) +`; + const rows = parseLsofOutput(output); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual({ pid: 12345, port: 5173, host: "127.0.0.1" }); + expect(rows[1]).toEqual({ pid: 12345, port: 5173, host: "::1" }); + expect(rows[2]).toEqual({ pid: 99999, port: 3000, host: "0.0.0.0" }); + }); + + it("returns empty array for empty output", () => { + expect(parseLsofOutput("")).toEqual([]); + }); + + it("ignores rows not in LISTEN state", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 alice 23u IPv4 0xabc12345abc1234 0t0 TCP 127.0.0.1:5173->127.0.0.1:99 (ESTABLISHED) +`; + expect(parseLsofOutput(output)).toEqual([]); + }); +}); diff --git a/apps/server/src/detectedServers/SocketProbe.Linux.test.ts b/apps/server/src/detectedServers/SocketProbe.Linux.test.ts new file mode 100644 index 00000000000..bbf6a8769e9 --- /dev/null +++ b/apps/server/src/detectedServers/SocketProbe.Linux.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { readFileSync } from "node:fs"; +import { parseProcTcpRows, parseProcTcp6Rows } from "./Layers/SocketProbe.Linux.ts"; + +const fixture = (name: string) => + readFileSync(join(import.meta.dirname, "__fixtures__/proc", `${name}.txt`), "utf8"); + +describe("SocketProbe.Linux parsers", () => { + it("parses LISTEN sockets from /proc//net/tcp", () => { + const rows = parseProcTcpRows(fixture("tcp")); + const listening = rows.filter((r) => r.state === "LISTEN"); + expect(listening).toHaveLength(2); + expect(listening[0]!.port).toBe(5201); + expect(listening[0]!.host).toBe("127.0.0.1"); + expect(listening[1]!.port).toBe(5301); + expect(listening[1]!.host).toBe("0.0.0.0"); + }); + + it("excludes non-LISTEN rows", () => { + const rows = parseProcTcpRows(fixture("tcp")); + const established = rows.find((r) => r.state !== "LISTEN"); + expect(established?.port).toBe(5202); + }); + + it("parses LISTEN sockets from /proc//net/tcp6", () => { + const rows = parseProcTcp6Rows(fixture("tcp6")); + const listening = rows.filter((r) => r.state === "LISTEN"); + expect(listening).toHaveLength(1); + expect(listening[0]!.port).toBe(8080); + }); +}); diff --git a/apps/server/src/detectedServers/SocketProbe.Windows.test.ts b/apps/server/src/detectedServers/SocketProbe.Windows.test.ts new file mode 100644 index 00000000000..0035e9bb793 --- /dev/null +++ b/apps/server/src/detectedServers/SocketProbe.Windows.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { parseNetstatOutput } from "./Layers/SocketProbe.Windows.ts"; + +describe("SocketProbe.Windows.parseNetstatOutput", () => { + it("parses LISTENING rows", () => { + const output = ` +Active Connections + + Proto Local Address Foreign Address State PID + TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234 + TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 9876 + TCP 127.0.0.1:5173 127.0.0.1:54321 ESTABLISHED 9876 +`; + const rows = parseNetstatOutput(output); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ pid: 1234, port: 135, host: "0.0.0.0" }); + expect(rows[1]).toEqual({ pid: 9876, port: 5173, host: "127.0.0.1" }); + }); + + it("handles IPv6 brackets", () => { + const output = ` + TCP [::]:8080 [::]:0 LISTENING 4242 +`; + const rows = parseNetstatOutput(output); + expect(rows).toEqual([{ pid: 4242, port: 8080, host: "::" }]); + }); + + it("returns empty for no LISTENING rows", () => { + expect(parseNetstatOutput("")).toEqual([]); + }); +}); diff --git a/apps/server/src/detectedServers/StdoutSniffer.test.ts b/apps/server/src/detectedServers/StdoutSniffer.test.ts new file mode 100644 index 00000000000..081d3166fee --- /dev/null +++ b/apps/server/src/detectedServers/StdoutSniffer.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { StdoutSniffer } from "./Layers/StdoutSniffer.ts"; + +const fixturePath = (name: string) => + join(import.meta.dirname, "__fixtures__/stdout", `${name}.txt`); + +describe("StdoutSniffer", () => { + it("extracts Vite URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("vite"), "utf8")); + expect(out).toHaveLength(1); + expect(out[0]!.url).toBe("http://localhost:5173/"); + expect(out[0]!.port).toBe(5173); + expect(out[0]!.framework).toBe("vite"); + }); + + it("extracts Next URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("next"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:3000"); + expect(out[0]!.framework).toBe("next"); + }); + + it("extracts Nuxt URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("nuxt"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:3000/"); + expect(out[0]!.framework).toBe("nuxt"); + }); + + it("extracts Astro URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("astro"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:4321/"); + expect(out[0]!.framework).toBe("astro"); + }); + + it("extracts Remix URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("remix"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:3000"); + expect(out[0]!.framework).toBe("remix"); + }); + + it("extracts Wrangler URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("wrangler"), "utf8")); + expect(out[0]!.url).toBe("http://127.0.0.1:8787"); + expect(out[0]!.framework).toBe("wrangler"); + }); + + it("extracts Webpack-DevServer URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("webpack"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:8080/"); + expect(out[0]!.framework).toBe("webpack"); + }); + + it("extracts generic Express port", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("express"), "utf8")); + expect(out[0]!.port).toBe(3000); + expect(out[0]!.framework).toBe("express"); + }); + + it("assembles URLs split across chunks", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed("Local: http://localho"); + sniffer.feed("st:5173/\n"); + expect(out).toHaveLength(1); + expect(out[0]!.url).toBe("http://localhost:5173/"); + }); + + it("strips ANSI before matching", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed("\x1b[36m ➜ Local:\x1b[0m \x1b[1mhttp://localhost:5173/\x1b[0m\n"); + expect(out[0]!.url).toBe("http://localhost:5173/"); + }); +}); diff --git a/apps/server/src/detectedServers/__fixtures__/proc/tcp.txt b/apps/server/src/detectedServers/__fixtures__/proc/tcp.txt new file mode 100644 index 00000000000..5b32c987719 --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/proc/tcp.txt @@ -0,0 +1,4 @@ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1451 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0 + 1: 00000000:14B5 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 67890 1 0000000000000000 100 0 0 10 0 + 2: 0100007F:1452 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 11111 1 0000000000000000 100 0 0 10 0 diff --git a/apps/server/src/detectedServers/__fixtures__/proc/tcp6.txt b/apps/server/src/detectedServers/__fixtures__/proc/tcp6.txt new file mode 100644 index 00000000000..b75f99de83d --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/proc/tcp6.txt @@ -0,0 +1,2 @@ + sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 22222 1 0000000000000000 100 0 0 10 0 diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/astro.txt b/apps/server/src/detectedServers/__fixtures__/stdout/astro.txt new file mode 100644 index 00000000000..3fa27a16534 --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/astro.txt @@ -0,0 +1,4 @@ + astro v4.0.7 ready in 145 ms + +┃ Local http://localhost:4321/ +┃ Network use --host to expose diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/express.txt b/apps/server/src/detectedServers/__fixtures__/stdout/express.txt new file mode 100644 index 00000000000..a94ade8fe8d --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/express.txt @@ -0,0 +1 @@ +Server listening on port 3000 diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/next.txt b/apps/server/src/detectedServers/__fixtures__/stdout/next.txt new file mode 100644 index 00000000000..538c07ecd47 --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/next.txt @@ -0,0 +1,5 @@ + ▲ Next.js 14.0.4 + - Local: http://localhost:3000 + - Environments: .env + + ✓ Ready in 1.2s diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/nuxt.txt b/apps/server/src/detectedServers/__fixtures__/stdout/nuxt.txt new file mode 100644 index 00000000000..2be2d4c282e --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/nuxt.txt @@ -0,0 +1,8 @@ +ℹ Vite client warmed up in 432ms +ℹ Vite server warmed up in 451ms + + Nuxt 3.10.0 with Nitro 2.8.1 + + + ➜ Local: http://localhost:3000/ + ➜ Network: use --host to expose diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/remix.txt b/apps/server/src/detectedServers/__fixtures__/stdout/remix.txt new file mode 100644 index 00000000000..a3a66914123 --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/remix.txt @@ -0,0 +1,3 @@ + 💿 remix dev + + info serving HTTP on http://localhost:3000 diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/vite.txt b/apps/server/src/detectedServers/__fixtures__/stdout/vite.txt new file mode 100644 index 00000000000..9fc071778a5 --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/vite.txt @@ -0,0 +1,4 @@ + VITE v5.0.10 ready in 312 ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/webpack.txt b/apps/server/src/detectedServers/__fixtures__/stdout/webpack.txt new file mode 100644 index 00000000000..061a27da65f --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/webpack.txt @@ -0,0 +1,3 @@ + [webpack-dev-server] Project is running at: + [webpack-dev-server] Loopback: http://localhost:8080/ + [webpack-dev-server] On Your Network (IPv4): http://192.168.1.42:8080/ diff --git a/apps/server/src/detectedServers/__fixtures__/stdout/wrangler.txt b/apps/server/src/detectedServers/__fixtures__/stdout/wrangler.txt new file mode 100644 index 00000000000..be7cf78f0ce --- /dev/null +++ b/apps/server/src/detectedServers/__fixtures__/stdout/wrangler.txt @@ -0,0 +1,3 @@ +⛅️ wrangler 3.20.0 + +[mf:inf] Ready on http://127.0.0.1:8787 diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 1f512c4c3fb..0493197c6f6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -40,6 +40,7 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; const DRIVER_KIND = ProviderDriverKind.make("codex"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); @@ -54,7 +55,8 @@ export type CodexDriverEnv = | FileSystem.FileSystem | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | DetectedServersIngress; /** * Stamp instance identity onto a `ServerProvider` snapshot produced by the diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 125407cddce..6cfdf95b510 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -17,6 +17,7 @@ import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; @@ -43,7 +44,8 @@ export type CursorDriverEnv = | FileSystem.FileSystem | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | DetectedServersIngress; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index fc6452344c2..30ec9debcbf 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -26,6 +26,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { buildAgentTokenModeInstructions } from "../../tokenReduction.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; @@ -210,6 +211,11 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory listBindings: () => Effect.succeed([]), }); +const detectedServersIngressTestLayer = Layer.succeed(DetectedServersIngress, { + trackAgentCommand: () => Effect.succeed({ feed: () => {}, end: () => {} }), + trackPty: () => Effect.succeed({ feed: () => {}, feedCommand: () => {}, end: () => {} }), +}); + const validationRuntimeFactory = makeRuntimeFactory(); const validationLayer = it.layer( Layer.effect( @@ -225,6 +231,7 @@ const validationLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ); @@ -300,6 +307,7 @@ const sessionErrorLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ); @@ -372,6 +380,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ); return Effect.gen(function* () { @@ -426,6 +435,7 @@ const lifecycleLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ); @@ -1005,6 +1015,7 @@ const scopedLifecycleLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ); @@ -1049,6 +1060,7 @@ const scopedFailureLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ); @@ -1099,6 +1111,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ); const context = yield* Layer.buildWithScope(layer, scope); const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context)); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index df78b9fcc52..fe7aa5dd9a5 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -48,6 +48,7 @@ import { import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { buildTokenReductionInstructions, checkRtkAvailability } from "../../tokenReduction.ts"; import { CodexResumeCursorSchema, @@ -69,7 +70,7 @@ export interface CodexAdapterLiveOptions { ) => Effect.Effect< CodexSessionRuntimeShape, CodexSessionRuntimeError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | DetectedServersIngress >; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; @@ -1362,6 +1363,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const detectedServersIngress = yield* DetectedServersIngress; const serverConfig = yield* Effect.service(ServerConfig); const nativeEventLogger = options?.nativeEventLogger ?? @@ -1423,6 +1425,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const runtime = yield* createRuntime(runtimeInput).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(DetectedServersIngress, detectedServersIngress), Effect.mapError( (cause) => new ProviderAdapterProcessError({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b9cee096c65..5e67338bc63 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -26,6 +26,10 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { + DetectedServersIngress, + type CommandTracker, +} from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { @@ -695,11 +699,13 @@ export const makeCodexSessionRuntime = ( ): Effect.Effect< CodexSessionRuntimeShape, CodexErrors.CodexAppServerError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | DetectedServersIngress > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; + const detectedServers = yield* DetectedServersIngress; + const trackerMap = new Map(); const events = yield* Queue.unbounded(); const pendingApprovalsRef = yield* Ref.make(new Map()); const approvalCorrelationsRef = yield* Ref.make(new Map()); @@ -803,6 +809,22 @@ export const makeCodexSessionRuntime = ( const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { + if (notification.method === "item/commandExecution/outputDelta") { + const p = notification.params; + trackerMap.get(`${p.turnId}::${p.itemId}`)?.feed(p.delta); + } else if (notification.method === "item/completed") { + const p = notification.params; + if (p.item.type === "commandExecution") { + const key = `${p.turnId}::${p.item.id}`; + const tracker = trackerMap.get(key); + if (tracker) { + const success = p.item.status === "completed"; + tracker.end(success ? "success" : "error"); + trackerMap.delete(key); + } + } + } + const payload = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); @@ -967,6 +989,15 @@ export const makeCodexSessionRuntime = ( payload, }); + const argv = payload.command ? payload.command.split(/\s+/).filter(Boolean) : []; + const cwd = payload.cwd ?? options.cwd; + const tracker = yield* detectedServers.trackAgentCommand( + { threadId: options.threadId, turnId: payload.turnId, itemId: payload.itemId, argv, cwd }, + "codex", + undefined, + ); + trackerMap.set(`${payload.turnId}::${payload.itemId}`, tracker); + const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( Ref.update(pendingApprovalsRef, (current) => { @@ -1218,6 +1249,10 @@ export const makeCodexSessionRuntime = ( if (alreadyClosed) { return; } + for (const tracker of trackerMap.values()) { + tracker.end("error"); + } + trackerMap.clear(); yield* settlePendingApprovals("cancel"); yield* settlePendingUserInputs({}); yield* updateSession(sessionRef, { diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index e32fb17a529..8237b353d17 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -19,6 +19,7 @@ import { import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; @@ -119,6 +120,11 @@ const makeResolveCursorSettings = Effect.gen(function* () { ); }); +const detectedServersIngressTestLayer = Layer.succeed(DetectedServersIngress, { + trackAgentCommand: () => Effect.succeed({ feed: () => {}, end: () => {} }), + trackPty: () => Effect.succeed({ feed: () => {}, feedCommand: () => {}, end: () => {} }), +}); + const cursorAdapterTestLayer = it.layer( Layer.effect( CursorAdapter, @@ -135,6 +141,7 @@ const cursorAdapterTestLayer = it.layer( }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ); @@ -610,6 +617,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ), ), ), @@ -1245,6 +1253,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(detectedServersIngressTestLayer), ); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 7406453f8e4..6467ba026d1 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -43,6 +43,10 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { + DetectedServersIngress, + type CommandTracker, +} from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -50,6 +54,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { AcpDetailSuffixDedup } from "../acp/AcpDetectedServersTap.ts"; import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, @@ -309,6 +314,9 @@ export function makeCursorAdapter( const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const detectedServers = yield* DetectedServersIngress; + const trackerMap = new Map(); + const detailDedup = new AcpDetailSuffixDedup(); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -771,6 +779,44 @@ export function makeCursorAdapter( event.rawPayload, "acp.jsonrpc", ); + if (event.toolCall.kind === "execute") { + const toolCallId = event.toolCall.toolCallId; + const toolStatus = event.toolCall.status; + const trackerKey = `${ctx.threadId}::${toolCallId}`; + const existingTracker = trackerMap.get(trackerKey); + if (!existingTracker) { + const argv = event.toolCall.command + ? event.toolCall.command.split(/\s+/).filter(Boolean) + : []; + const tracker = yield* detectedServers.trackAgentCommand( + { + threadId: ctx.threadId, + turnId: ctx.activeTurnId ?? toolCallId, + itemId: toolCallId, + argv, + cwd: ctx.session.cwd ?? "", + }, + "acp", + undefined, + ); + trackerMap.set(trackerKey, tracker); + if (toolStatus === "completed" || toolStatus === "failed") { + tracker.end(toolStatus === "completed" ? "success" : "error"); + trackerMap.delete(trackerKey); + detailDedup.reset(toolCallId); + } else if (event.toolCall.detail) { + const suffix = detailDedup.consume(toolCallId, event.toolCall.detail); + if (suffix !== null) tracker.feed(suffix); + } + } else if (toolStatus === "completed" || toolStatus === "failed") { + existingTracker.end(toolStatus === "completed" ? "success" : "error"); + trackerMap.delete(trackerKey); + detailDedup.reset(toolCallId); + } else if (event.toolCall.detail) { + const suffix = detailDedup.consume(toolCallId, event.toolCall.detail); + if (suffix !== null) existingTracker.feed(suffix); + } + } yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp: yield* makeEventStamp(), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 184547d8fc3..f46487af9a8 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -36,10 +36,12 @@ import { import { Effect, Layer } from "effect"; import { ServerConfig } from "../../config.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -79,6 +81,11 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const detectedServersIngressTestLayer = Layer.succeed(DetectedServersIngress, { + trackAgentCommand: () => Effect.succeed({ feed: () => {}, end: () => {} }), + trackPty: () => Effect.succeed({ feed: () => {}, feedCommand: () => {}, end: () => {} }), +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -90,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(detectedServersIngressTestLayer), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -226,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }).pipe( Layer.provideMerge(infraLayer), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(detectedServersIngressTestLayer), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -271,7 +280,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], + drivers: BUILT_IN_DRIVERS, configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 175e4550dc4..53caed5da25 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -30,6 +30,7 @@ import { } from "./ProviderRegistry.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; @@ -44,6 +45,11 @@ process.env.RYCO_CURSOR_ENABLED = "1"; // ── Test helpers ──────────────────────────────────────────────────── +const detectedServersIngressTestLayer = Layer.succeed(DetectedServersIngress, { + trackAgentCommand: () => Effect.succeed({ feed: () => {}, end: () => {} }), + trackPty: () => Effect.succeed({ feed: () => {}, feedCommand: () => {}, end: () => {} }), +}); + const encoder = new TextEncoder(); function selectDescriptor( @@ -877,7 +883,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide(detectedServersIngressTestLayer), + ), + ), Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -971,7 +981,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide(detectedServersIngressTestLayer), + ), + ), Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1085,7 +1099,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide(detectedServersIngressTestLayer), + ), + ), Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1135,7 +1153,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide(detectedServersIngressTestLayer), + ), + ), Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/provider/acp/AcpDetectedServersTap.test.ts b/apps/server/src/provider/acp/AcpDetectedServersTap.test.ts new file mode 100644 index 00000000000..ef5d9602641 --- /dev/null +++ b/apps/server/src/provider/acp/AcpDetectedServersTap.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { AcpDetailSuffixDedup } from "./AcpDetectedServersTap.ts"; + +describe("AcpDetailSuffixDedup", () => { + it("returns the new suffix on each cumulative growth", () => { + const dedup = new AcpDetailSuffixDedup(); + expect(dedup.consume("call-1", "Local: http://")).toBe("Local: http://"); + expect(dedup.consume("call-1", "Local: http://localhost")).toBe("localhost"); + expect(dedup.consume("call-1", "Local: http://localhost:5173/\nready")).toBe(":5173/\nready"); + }); + + it("returns null when detail length did not change", () => { + const dedup = new AcpDetailSuffixDedup(); + dedup.consume("call-1", "abcdef"); + expect(dedup.consume("call-1", "abcdef")).toBeNull(); + }); + + it("re-feeds full detail when the cumulative text shrinks", () => { + const dedup = new AcpDetailSuffixDedup(); + dedup.consume("call-1", "Local: http://localhost:5173/"); + expect(dedup.consume("call-1", "restarted")).toBe("restarted"); + expect(dedup.consume("call-1", "restarted now")).toBe(" now"); + }); + + it("scopes per toolCallId", () => { + const dedup = new AcpDetailSuffixDedup(); + expect(dedup.consume("call-a", "alpha")).toBe("alpha"); + expect(dedup.consume("call-b", "beta")).toBe("beta"); + expect(dedup.consume("call-a", "alpha-extra")).toBe("-extra"); + expect(dedup.consume("call-b", "beta-extra")).toBe("-extra"); + }); + + it("reset clears the per-key length so the next consume is a full feed", () => { + const dedup = new AcpDetailSuffixDedup(); + dedup.consume("call-1", "abc"); + dedup.reset("call-1"); + expect(dedup.consume("call-1", "abcdef")).toBe("abcdef"); + }); + + it("integrates with a tracker stub: only the suffix is sent on a growing sequence", () => { + const dedup = new AcpDetailSuffixDedup(); + const fed: string[] = []; + const feed = (text: string) => fed.push(text); + + const updates = [ + "running vite\n", + "running vite\nVITE v5.0.0\n", + "running vite\nVITE v5.0.0\nLocal: http://localhost:5173/\n", + ]; + + for (const detail of updates) { + const suffix = dedup.consume("tool-1", detail); + if (suffix !== null) feed(suffix); + } + + expect(fed).toEqual(["running vite\n", "VITE v5.0.0\n", "Local: http://localhost:5173/\n"]); + }); +}); diff --git a/apps/server/src/provider/acp/AcpDetectedServersTap.ts b/apps/server/src/provider/acp/AcpDetectedServersTap.ts new file mode 100644 index 00000000000..b9afc1fa6ad --- /dev/null +++ b/apps/server/src/provider/acp/AcpDetectedServersTap.ts @@ -0,0 +1,37 @@ +/** + * Helpers for taping ACP tool-call telemetry into the detected-servers + * pipeline. + * + * ACP `toolCall.detail` is cumulative: each `session/update` ToolCallUpdated + * REPLACES the previous text with a longer version. Forwarding the full + * detail to the StdoutSniffer on every update would replay lines we already + * saw, emitting duplicate registry events. This dedup remembers the last + * length forwarded per toolCallId and only returns the new suffix. + */ +export class AcpDetailSuffixDedup { + private readonly lengthByKey = new Map(); + + /** + * Returns the new suffix to feed to a tracker, or `null` if nothing new. + * + * If the detail string shrank (defensive: shouldn't happen), the dedup is + * reset and the full new string is returned so the tracker can re-process + * it from scratch. + */ + consume(key: string, detail: string): string | null { + const previousLength = this.lengthByKey.get(key) ?? 0; + if (detail.length < previousLength) { + this.lengthByKey.set(key, detail.length); + return detail; + } + if (detail.length > previousLength) { + this.lengthByKey.set(key, detail.length); + return detail.slice(previousLength); + } + return null; + } + + reset(key: string): void { + this.lengthByKey.delete(key); + } +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8e8caf4cd92..e2056c18f7a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -121,6 +121,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import { DetectedServerRegistry } from "./detectedServers/Services/DetectedServerRegistry.ts"; import { AtlassianConnectionService, type AtlassianConnectionServiceShape, @@ -610,6 +611,17 @@ const buildAppUnderTest = (options?: { ...options?.layers?.terminalManager, }), ), + Layer.provide( + Layer.mock(DetectedServerRegistry)({ + registerOrUpdate: () => + Effect.die("DetectedServerRegistry.registerOrUpdate not implemented in test"), + publishLog: () => Effect.void, + remove: () => Effect.void, + subscribe: (_threadId, _listener) => Effect.succeed(() => {}), + getCurrent: () => Effect.succeed([]), + findById: () => Effect.succeed(undefined), + }), + ), Layer.provide( Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4628e112f96..a1014647719 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,6 +36,10 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import { SocketProbeLive } from "./detectedServers/Layers/SocketProbeLive.ts"; +import { LivenessHeartbeatLive } from "./detectedServers/Layers/LivenessHeartbeat.ts"; +import { DetectedServerRegistryLive } from "./detectedServers/Services/DetectedServerRegistry.ts"; +import { DetectedServersIngressLive } from "./detectedServers/Layers/DetectedServersIngress.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; @@ -232,7 +236,21 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const DetectedServersLayerLive = Layer.mergeAll( + SocketProbeLive, + LivenessHeartbeatLive, + DetectedServerRegistryLive, + DetectedServersIngressLive.pipe( + Layer.provide(DetectedServerRegistryLive), + Layer.provide(SocketProbeLive), + Layer.provide(LivenessHeartbeatLive), + ), +); + +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(DetectedServersLayerLive), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), @@ -278,7 +296,7 @@ const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, DetectedServersLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(ProjectionWorktreeRepositoryLive), Layer.provideMerge(KeybindingsLive), @@ -288,7 +306,9 @@ const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + ProviderInstanceRegistryHydrationLive.pipe(Layer.provide(DetectedServersLayerLive)), + ), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 94315e97270..af064ad94a3 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -8,6 +8,7 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@ryco/contracts"; +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; import { Duration, Effect, @@ -15,6 +16,7 @@ import { Exit, Fiber, FileSystem, + Layer, Option, PlatformError, Ref, @@ -217,7 +219,7 @@ const createManager = ( ): Effect.Effect< ManagerFixture, PlatformError.PlatformError, - FileSystem.FileSystem | Scope.Scope + FileSystem.FileSystem | Scope.Scope | DetectedServersIngress > => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { @@ -262,7 +264,14 @@ const createManager = ( }), ); -it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => { +const detectedServersIngressTestLayer = Layer.succeed(DetectedServersIngress, { + trackAgentCommand: () => Effect.succeed({ feed: () => {}, end: () => {} }), + trackPty: () => Effect.succeed({ feed: () => {}, feedCommand: () => {}, end: () => {} }), +}); + +it.layer(Layer.mergeAll(NodeServices.layer, detectedServersIngressTestLayer), { + excludeTestServices: true, +})("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 5375ab54230..5b6ddd6d6a2 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -29,6 +29,11 @@ import { terminalSessionsTotal, } from "../../observability/Metrics.ts"; import { runProcess } from "../../processRunner.ts"; +import { + DetectedServersIngress, + type PtyTracker, +} from "../../detectedServers/Layers/DetectedServersIngress.ts"; +import { PtyInputLineBuffer, tokenizeShellLine } from "../../detectedServers/PtyInputLineBuffer.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -114,6 +119,8 @@ interface TerminalSessionState { unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; runtimeEnv: Record | null; + detectedServersTracker: PtyTracker | null; + detectedServersInputBuffer: PtyInputLineBuffer | null; } interface PersistHistoryRequest { @@ -714,6 +721,7 @@ const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( function* (options: TerminalManagerOptions) { const fileSystem = yield* FileSystem.FileSystem; + const detectedServers = yield* DetectedServersIngress; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -1223,6 +1231,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith : null; session.updatedAt = new Date().toISOString(); + const tracker = session.detectedServersTracker; + session.detectedServersTracker = null; + session.detectedServersInputBuffer = null; + tracker?.end(session.exitCode); + return { type: "exit", process, @@ -1283,6 +1296,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; session.updatedAt = new Date().toISOString(); + const tracker = session.detectedServersTracker; + session.detectedServersTracker = null; + tracker?.end(null); return [undefined, state] as const; }); @@ -1391,7 +1407,28 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith startedShell = spawnResult.shellLabel; const processPid = ptyProcess.pid; + + // Create a detected-servers tracker for this PTY spawn. + // ArgvHinter will classify shell commands (bash/zsh) as non-server + // and return a noop tracker — this is expected for v1 (Option B). + const ptyTracker = yield* detectedServers.trackPty( + { + threadId: session.threadId, + terminalId: session.terminalId, + pid: processPid, + argv: [spawnResult.shellLabel], + cwd: session.cwd, + }, + undefined, + ); + const ptyInputBuffer = new PtyInputLineBuffer((line) => { + const argv = tokenizeShellLine(line); + if (argv.length === 0) return; + ptyTracker.feedCommand(argv, session.cwd); + }); + const unsubscribeData = ptyProcess.onData((data) => { + ptyTracker.feed(data); if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { return; } @@ -1411,6 +1448,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.updatedAt = new Date().toISOString(); session.unsubscribeData = unsubscribeData; session.unsubscribeExit = unsubscribeExit; + session.detectedServersTracker = ptyTracker; + session.detectedServersInputBuffer = ptyInputBuffer; return [undefined, state] as const; }); @@ -1447,6 +1486,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; session.updatedAt = new Date().toISOString(); + const tracker = session.detectedServersTracker; + session.detectedServersTracker = null; + session.detectedServersInputBuffer = null; + tracker?.end(null); return [undefined, state] as const; }); @@ -1603,6 +1646,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session: TerminalSessionState, ) { cleanupProcessHandles(session); + const tracker = session.detectedServersTracker; + session.detectedServersTracker = null; + session.detectedServersInputBuffer = null; + tracker?.end(null); if (!session.process) return; yield* clearKillFiber(session.process); yield* runKillEscalation(session.process, session.threadId, session.terminalId); @@ -1651,6 +1698,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith unsubscribeExit: null, hasRunningSubprocess: false, runtimeEnv: normalizedRuntimeEnv(input.env), + detectedServersTracker: null, + detectedServersInputBuffer: null, }; const createdSession = session; @@ -1753,6 +1802,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith terminalId, }); } + session.detectedServersInputBuffer?.write(input.data); yield* Effect.sync(() => process.write(input.data)); }); @@ -1830,6 +1880,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith unsubscribeExit: null, hasRunningSubprocess: false, runtimeEnv: normalizedRuntimeEnv(input.env), + detectedServersTracker: null, + detectedServersInputBuffer: null, }; const createdSession = session; yield* modifyManagerState((state) => { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6631450dbc..a7e1269eebd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -28,6 +28,7 @@ import { OpinionatedPluginError, FilesystemBrowseError, ThreadId, + type DetectedServerEvent, type TerminalEvent, WorktreeId, WS_METHODS, @@ -95,9 +96,14 @@ import { type SessionCredentialChange, } from "./auth/Services/SessionCredentialService.ts"; import { respondToAuthError } from "./auth/http.ts"; +import { DetectedServerRegistry } from "./detectedServers/Services/DetectedServerRegistry.ts"; import { authorizeWsRpc, type WsRpcAccess } from "./auth/wsAuthorization.ts"; import { AtlassianConnectionService } from "./atlassian/AtlassianConnectionService.ts"; import { JiraWorkItemService } from "./atlassian/JiraWorkItemService.ts"; +import { + handleDetectedServerOpenInBrowser, + handleDetectedServerStop, +} from "./detectedServers/Handlers.ts"; function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, @@ -247,6 +253,7 @@ const makeWsRpcLayer = (session: AuthenticatedSession) => const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const projectionWorktrees = yield* ProjectionWorktreeRepository; + const detectedServerRegistry = yield* DetectedServerRegistry; const atlassian = yield* AtlassianConnectionService; const workItems = yield* JiraWorkItemService; const serverCommandId = (tag: string) => @@ -2455,6 +2462,43 @@ const makeWsRpcLayer = (session: AuthenticatedSession) => ), { "rpc.aggregate": "auth" }, ), + [WS_METHODS.subscribeDetectedServerEvents]: (input) => + observeRpcStreamEffect( + WS_METHODS.subscribeDetectedServerEvents, + Effect.gen(function* () { + const snapshot = yield* detectedServerRegistry.getCurrent(input.threadId); + const snapshotEvents: DetectedServerEvent[] = snapshot.map((server) => ({ + type: "registered" as const, + threadId: input.threadId, + server, + createdAt: new Date().toISOString(), + })); + const services = yield* Effect.context(); + const runSyncOffer = Effect.runSyncWith(services); + const liveStream = Stream.callback((queue) => + Effect.acquireRelease( + detectedServerRegistry.subscribe(input.threadId, (event) => { + runSyncOffer(Queue.offer(queue, event)); + }), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ); + return Stream.concat(Stream.fromIterable(snapshotEvents), liveStream); + }), + { "rpc.aggregate": "detectedServers" }, + ), + [WS_METHODS.detectedServersStop]: (input) => + observeRpcEffect( + WS_METHODS.detectedServersStop, + handleDetectedServerStop(detectedServerRegistry, terminalManager, input.serverId), + { "rpc.aggregate": "detectedServers" }, + ), + [WS_METHODS.detectedServersOpenInBrowser]: (input) => + observeRpcEffect( + WS_METHODS.detectedServersOpenInBrowser, + handleDetectedServerOpenInBrowser(detectedServerRegistry, open, input.serverId), + { "rpc.aggregate": "detectedServers" }, + ), }); }), ); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 93bec14b261..48fa5959904 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,14 +1,16 @@ -import { scopeProjectRef, scopeThreadRef } from "@ryco/client-runtime"; +import { scopeProjectRef, scopeThreadRef, scopedThreadKey } from "@ryco/client-runtime"; import type { EnvironmentId, ThreadId } from "@ryco/contracts"; import { TerminalSquareIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useDetectedServerStore } from "../detectedServerStore"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { type EnvironmentOption } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; +import { DetectedServersBadge } from "./BranchToolbar/DetectedServersBadge"; import { Button } from "./ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -28,6 +30,7 @@ interface BranchToolbarProps { terminalToggleShortcutLabel: string | null; onToggleTerminal: () => void; terminalCount: number; + onOpenServersTab?: () => void; } export const BranchToolbar = memo(function BranchToolbar({ @@ -46,6 +49,7 @@ export const BranchToolbar = memo(function BranchToolbar({ terminalToggleShortcutLabel, onToggleTerminal, terminalCount, + onOpenServersTab, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -68,6 +72,10 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeProject = useStore(activeProjectSelector); const hasActiveThread = serverThread !== undefined || draftThread !== null; + const threadKey = useMemo(() => scopedThreadKey(threadRef), [threadRef]); + const serversMap = useDetectedServerStore((s) => s.serversByThreadKey[threadKey]); + const detectedServers = useMemo(() => (serversMap ? [...serversMap.values()] : []), [serversMap]); + const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); @@ -121,6 +129,7 @@ export const BranchToolbar = memo(function BranchToolbar({ : "Toggle terminal drawer"} + {})} /> ): DetectedServer => ({ + id: "s", + threadId: "t", + source: "pty", + framework: "vite", + status: "live", + startedAt: new Date() as any, + lastSeenAt: new Date() as any, + ...overrides, +}); + +describe("DetectedServersBadge", () => { + it("renders nothing when no servers", () => { + const markup = renderToStaticMarkup( {}} />); + expect(markup).toBe(""); + }); + + it("renders count when 1+ servers", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + expect(markup).toContain(">1<"); + }); + + it("applies pulsing data-state when any server is predicted or candidate", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + expect(markup).toContain('data-state="pulsing"'); + }); + + it("no pulsing data-state when all servers are live", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + expect(markup).not.toContain('data-state="pulsing"'); + }); +}); diff --git a/apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx b/apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx new file mode 100644 index 00000000000..9c3467a7377 --- /dev/null +++ b/apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx @@ -0,0 +1,41 @@ +import { Server } from "lucide-react"; +import type { DetectedServer } from "@ryco/contracts"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface Props { + servers: DetectedServer[]; + onClick: () => void; +} + +export const DetectedServersBadge = ({ servers, onClick }: Props) => { + if (servers.length === 0) return null; + const isPulsing = servers.some((s) => s.status === "predicted" || s.status === "candidate"); + return ( + + + + {servers.length} + + } + /> + +
    + {servers.map((s) => ( +
  • + {s.framework} + {s.url ? · {s.url} : null} + · {s.status} +
  • + ))} +
+
+
+ ); +}; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bb8df506032..efe871956c1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -209,6 +209,7 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { useDetectedServersSubscription } from "../hooks/useDetectedServersSubscription"; import { RightPanelSheet } from "./RightPanelSheet"; import { Button } from "./ui/button"; import { @@ -786,6 +787,7 @@ export default function ChatView(props: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const storeSetTerminalDrawerKind = useTerminalStateStore((s) => s.setTerminalDrawerKind); const serverThreadKeys = useStore( useShallow((state) => selectThreadsAcrossEnvironments(state).map((thread) => @@ -1061,6 +1063,10 @@ export default function ChatView(props: ChatViewProps) { return retainThreadDetailSubscription(environmentId, threadId); }, [environmentId, routeKind, threadId]); + // Subscribe to detected-server events and dispatch into the detected-server store. + // Scoped to the active server-thread; the store is keyed by threadKey. + useDetectedServersSubscription(routeKind === "server" ? environmentId : null, threadId); + // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); @@ -2040,6 +2046,11 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) return; setTerminalOpen(!terminalState.terminalOpen); }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); + const openServersTab = useCallback(() => { + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, true); + storeSetTerminalDrawerKind(scopedThreadKey(activeThreadRef), "servers"); + }, [activeThreadRef, storeSetTerminalOpen, storeSetTerminalDrawerKind]); const splitTerminal = useCallback(() => { if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; @@ -4036,6 +4047,7 @@ export default function ChatView(props: ChatViewProps) { terminalToggleShortcutLabel={terminalToggleShortcutLabel} onToggleTerminal={toggleTerminalVisibility} terminalCount={terminalState.terminalIds.length} + onOpenServersTab={openServersTab} /> )} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ee70e658fde..b7ed03f06f5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -7,6 +7,7 @@ import { type TerminalSessionSnapshot, type ThreadId, } from "@ryco/contracts"; +import { scopedThreadKey } from "@ryco/client-runtime"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, @@ -20,6 +21,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; +import { cn } from "~/lib/utils"; import { openInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, @@ -48,6 +50,7 @@ import { import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; +import { DetectedServersPanel } from "./detectedServers/DetectedServersPanel.tsx"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -891,6 +894,12 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext, keybindings, }: ThreadTerminalDrawerProps) { + const threadKey = scopedThreadKey(threadRef); + const drawerKind = useTerminalStateStore( + (s) => s.terminalDrawerKindByThreadKey[threadKey] ?? "terminals", + ); + const setDrawerKind = useTerminalStateStore((s) => s.setTerminalDrawerKind); + const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); @@ -1139,173 +1148,210 @@ export default function ThreadTerminalDrawer({ onPointerCancel={handleResizePointerEnd} /> -
-
- {resolvedTerminalGroups.map((group, groupIndex) => { - const isActive = groupIndex === resolvedActiveGroupIndex; - const isRunning = groupHasRunningTerminal(group, normalizedRunningTerminalIds); - const label = resolveTabLabel(group, groupIndex + 1); - const groupActiveTerminalId = group.terminalIds.includes(resolvedActiveTerminalId) - ? resolvedActiveTerminalId - : (group.terminalIds[0] ?? resolvedActiveTerminalId); - const canCloseTab = resolvedTerminalGroups.length > 1; - const closeTabLabel = `Close ${label}`; - return ( -
- - {canCloseTab && ( - - { - event.stopPropagation(); - for (const terminalId of group.terminalIds) { - onCloseTerminal(terminalId); - } - }} - aria-label={closeTabLabel} - /> - } - > - - - - {closeTabLabel} - - - )} -
- ); - })} - - - -
-
- - - - onCloseTerminal(resolvedActiveTerminalId)} - label={closeTerminalActionLabel} - > - - -
+
+ +
-
- {isSplitView ? ( -
- {visibleTerminalIds.map((terminalId) => ( -
{ - if (terminalId !== resolvedActiveTerminalId) { - onActiveTerminalChange(terminalId); - } - }} - > -
- onCloseTerminal(terminalId)} - onAddTerminalContext={onAddTerminalContext} - focusRequestId={focusRequestId} - autoFocus={terminalId === resolvedActiveTerminalId} - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} - keybindings={keybindings} - /> + {drawerKind === "terminals" && ( +
+
+ {resolvedTerminalGroups.map((group, groupIndex) => { + const isActive = groupIndex === resolvedActiveGroupIndex; + const isRunning = groupHasRunningTerminal(group, normalizedRunningTerminalIds); + const label = resolveTabLabel(group, groupIndex + 1); + const groupActiveTerminalId = group.terminalIds.includes(resolvedActiveTerminalId) + ? resolvedActiveTerminalId + : (group.terminalIds[0] ?? resolvedActiveTerminalId); + const canCloseTab = resolvedTerminalGroups.length > 1; + const closeTabLabel = `Close ${label}`; + return ( +
+ + {canCloseTab && ( + + { + event.stopPropagation(); + for (const terminalId of group.terminalIds) { + onCloseTerminal(terminalId); + } + }} + aria-label={closeTabLabel} + /> + } + > + + + + {closeTabLabel} + + + )}
-
- ))} + ); + })} + + +
- ) : ( -
- onCloseTerminal(resolvedActiveTerminalId)} - onAddTerminalContext={onAddTerminalContext} - focusRequestId={focusRequestId} - autoFocus - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} - keybindings={keybindings} - /> +
+ + + + onCloseTerminal(resolvedActiveTerminalId)} + label={closeTerminalActionLabel} + > + +
- )} -
+
+ )} + + {drawerKind === "terminals" ? ( +
+ {isSplitView ? ( +
+ {visibleTerminalIds.map((terminalId) => ( +
{ + if (terminalId !== resolvedActiveTerminalId) { + onActiveTerminalChange(terminalId); + } + }} + > +
+ onCloseTerminal(terminalId)} + onAddTerminalContext={onAddTerminalContext} + focusRequestId={focusRequestId} + autoFocus={terminalId === resolvedActiveTerminalId} + resizeEpoch={resizeEpoch} + drawerHeight={drawerHeight} + keybindings={keybindings} + /> +
+
+ ))} +
+ ) : ( +
+ onCloseTerminal(resolvedActiveTerminalId)} + onAddTerminalContext={onAddTerminalContext} + focusRequestId={focusRequestId} + autoFocus + resizeEpoch={resizeEpoch} + drawerHeight={drawerHeight} + keybindings={keybindings} + /> +
+ )} +
+ ) : ( +
+ +
+ )} ); } diff --git a/apps/web/src/components/detectedServers/DetectedServerLogView.tsx b/apps/web/src/components/detectedServers/DetectedServerLogView.tsx new file mode 100644 index 00000000000..7948d4547ec --- /dev/null +++ b/apps/web/src/components/detectedServers/DetectedServerLogView.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { useDetectedServerStore } from "../../detectedServerStore.ts"; + +interface Props { + serverId: string; +} + +export const DetectedServerLogView = ({ serverId }: Props) => { + const containerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const writtenLengthRef = useRef(0); + + const buffer = useDetectedServerStore((s) => s.logBuffersByServerId.get(serverId)); + const bufferLength = useDetectedServerStore((s) => { + const b = s.logBuffersByServerId.get(serverId); + return b ? b.snapshot().length : 0; + }); + + // Mount the terminal once + useEffect(() => { + if (!containerRef.current) return; + const term = new Terminal({ + convertEol: true, + disableStdin: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 12, + theme: { background: "transparent" }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(containerRef.current); + fit.fit(); + termRef.current = term; + fitRef.current = fit; + writtenLengthRef.current = 0; + return () => { + term.dispose(); + termRef.current = null; + fitRef.current = null; + }; + }, []); + + // Incremental write on buffer length change + useEffect(() => { + if (!termRef.current || !buffer) return; + const snap = buffer.snapshot(); + if (writtenLengthRef.current > snap.length) { + // Buffer trimmed from head — re-render from scratch + termRef.current.clear(); + writtenLengthRef.current = 0; + } + for (let i = writtenLengthRef.current; i < snap.length; i += 1) { + termRef.current.writeln(snap[i]!); + } + writtenLengthRef.current = snap.length; + }, [buffer, bufferLength]); + + // Resize on window resize + useEffect(() => { + const handler = () => fitRef.current?.fit(); + window.addEventListener("resize", handler); + return () => window.removeEventListener("resize", handler); + }, []); + + // Reset on serverId change + useEffect(() => { + if (!termRef.current) return; + termRef.current.clear(); + writtenLengthRef.current = 0; + }, [serverId]); + + return
; +}; diff --git a/apps/web/src/components/detectedServers/DetectedServerRow.test.tsx b/apps/web/src/components/detectedServers/DetectedServerRow.test.tsx new file mode 100644 index 00000000000..0b096e27adf --- /dev/null +++ b/apps/web/src/components/detectedServers/DetectedServerRow.test.tsx @@ -0,0 +1,83 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { DetectedServer } from "@ryco/contracts"; +import { DetectedServerRow } from "./DetectedServerRow.tsx"; + +const make = (overrides: Partial): DetectedServer => ({ + id: "s", + threadId: "t", + source: "pty", + framework: "vite", + status: "live", + url: "http://localhost:5173", + startedAt: new Date() as unknown as DetectedServer["startedAt"], + lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"], + ...overrides, +}); + +const noop = () => {}; + +const renderRow = (server: DetectedServer, active = false) => + renderToStaticMarkup( + , + ); + +describe("DetectedServerRow", () => { + it.each([ + ["predicted", "bg-blue-500/20"], + ["candidate", "bg-yellow-500/20"], + ["confirmed", "bg-cyan-500/20"], + ["live", "bg-green-500/20"], + ["restarting", "bg-orange-500/20"], + ["exited", "bg-muted"], + ["crashed", "bg-red-500/20"], + ] as const)("status pill class for %s contains %s", (status, cls) => { + const markup = renderRow(make({ status })); + expect(markup).toContain(cls); + }); + + it("hides Open and Copy buttons when the server has no url", () => { + const markup = renderRow(make({ url: undefined })); + expect(markup).not.toContain('aria-label="Open in browser"'); + expect(markup).not.toContain('aria-label="Copy URL"'); + }); + + it("renders Open and Copy buttons when the server has a url", () => { + const markup = renderRow(make({ url: "http://localhost:5173" })); + expect(markup).toContain('aria-label="Open in browser"'); + expect(markup).toContain('aria-label="Copy URL"'); + }); + + // The Tailwind class `disabled:opacity-30` itself contains the word "disabled", + // so we look for the standalone `disabled=""` HTML attribute instead. + const stopBtnDisabled = (markup: string): boolean => + /aria-label="Stop"[^>]* disabled=""/.test(markup); + + it("disables the Stop button when the server has exited", () => { + expect(stopBtnDisabled(renderRow(make({ status: "exited" })))).toBe(true); + }); + + it("disables the Stop button when the server has crashed", () => { + expect(stopBtnDisabled(renderRow(make({ status: "crashed" })))).toBe(true); + }); + + it("does not disable the Stop button for a live server", () => { + expect(stopBtnDisabled(renderRow(make({ status: "live" })))).toBe(false); + }); + + it("appends the bg-accent class when active=true (atop the always-on hover variant)", () => { + // The base button class ends with "hover:bg-accent". When active, the + // bare "bg-accent" class is appended after it. + const active = renderRow(make({}), true); + const inactive = renderRow(make({}), false); + expect(active.match(/\bbg-accent\b/g) ?? []).toHaveLength(2); + expect(inactive.match(/\bbg-accent\b/g) ?? []).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/detectedServers/DetectedServerRow.tsx b/apps/web/src/components/detectedServers/DetectedServerRow.tsx new file mode 100644 index 00000000000..7303788f31f --- /dev/null +++ b/apps/web/src/components/detectedServers/DetectedServerRow.tsx @@ -0,0 +1,91 @@ +import { Server, ExternalLink, Square, Copy } from "lucide-react"; +import type { DetectedServer } from "@ryco/contracts"; +import { cn } from "~/lib/utils"; + +const STATUS_PILL_CLASS: Record = { + predicted: "bg-blue-500/20 text-blue-300", + candidate: "bg-yellow-500/20 text-yellow-300 animate-pulse", + confirmed: "bg-cyan-500/20 text-cyan-300", + live: "bg-green-500/20 text-green-300", + restarting: "bg-orange-500/20 text-orange-300 animate-pulse", + exited: "bg-muted text-muted-foreground", + crashed: "bg-red-500/20 text-red-300", +}; + +interface Props { + server: DetectedServer; + active: boolean; + onSelect: () => void; + onOpen: () => void; + onCopy: () => void; + onStop: () => void; +} + +export const DetectedServerRow = ({ server, active, onSelect, onOpen, onCopy, onStop }: Props) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + className={cn( + "group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent", + active && "bg-accent", + )} + > + +
+
+ {server.framework} + + {server.status} + +
+
{server.url ?? "—"}
+
+
+ {server.url && ( + + )} + {server.url && ( + + )} + +
+
+); diff --git a/apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx b/apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx new file mode 100644 index 00000000000..2f171a3e239 --- /dev/null +++ b/apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx @@ -0,0 +1,75 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { DetectedServer } from "@ryco/contracts"; +import { DetectedServersPanelView } from "./DetectedServersPanel.tsx"; + +const make = (overrides: Partial): DetectedServer => ({ + id: "s", + threadId: "t", + source: "pty", + framework: "vite", + status: "live", + url: "http://localhost:5173", + startedAt: new Date() as unknown as DetectedServer["startedAt"], + lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"], + ...overrides, +}); + +const noop = () => {}; + +const renderView = ( + servers: DetectedServer[], + activeId: string | null = null, + handlers: Partial<{ + onSelect: (id: string) => void; + onOpen: (id: string) => void; + onCopy: (url: string) => void; + onStop: (id: string) => void; + }> = {}, +) => + renderToStaticMarkup( + s.id === activeId) ?? null) : null} + onSelect={handlers.onSelect ?? noop} + onOpen={handlers.onOpen ?? noop} + onCopy={handlers.onCopy ?? noop} + onStop={handlers.onStop ?? noop} + />, + ); + +describe("DetectedServersPanelView", () => { + it("renders the empty-state message when servers is empty", () => { + const markup = renderView([]); + expect(markup).toContain("No servers detected yet"); + }); + + it("renders one row per server (matching the size of the input list)", () => { + const markup = renderView([ + make({ id: "a", framework: "vite" }), + make({ id: "b", framework: "next" }), + make({ id: "c", framework: "remix" }), + ]); + expect(markup).toContain("vite"); + expect(markup).toContain("next"); + expect(markup).toContain("remix"); + expect((markup.match(/aria-label="Stop"/g) ?? []).length).toBe(3); + }); + + it("highlights the row whose id matches activeId via the appended bg-accent class", () => { + // bg-accent appears once per row in the always-on `hover:bg-accent` token, + // plus an additional bare `bg-accent` token on the active row only. + const markupActive = renderView([make({ id: "a" }), make({ id: "b" })], "b"); + const markupNone = renderView([make({ id: "a" }), make({ id: "b" })], null); + expect((markupActive.match(/\bbg-accent\b/g) ?? []).length).toBe(3); + expect((markupNone.match(/\bbg-accent\b/g) ?? []).length).toBe(2); + }); + + it("vi noop sanity (placeholder for future onStop handler verification)", () => { + const onStop = vi.fn(); + renderView([make({ id: "a" })], null, { onStop }); + // Static markup doesn't trigger handlers; this asserts the wiring shape only. + expect(onStop).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/detectedServers/DetectedServersPanel.tsx b/apps/web/src/components/detectedServers/DetectedServersPanel.tsx new file mode 100644 index 00000000000..6382c907932 --- /dev/null +++ b/apps/web/src/components/detectedServers/DetectedServersPanel.tsx @@ -0,0 +1,120 @@ +import { useMemo } from "react"; +import type { DetectedServer } from "@ryco/contracts"; +import { parseScopedThreadKey } from "@ryco/client-runtime"; +import { useDetectedServerStore } from "../../detectedServerStore.ts"; +import { readEnvironmentConnection } from "../../environments/runtime/service.ts"; +import { DetectedServerRow } from "./DetectedServerRow.tsx"; +import { DetectedServerLogView } from "./DetectedServerLogView.tsx"; + +interface ViewProps { + servers: ReadonlyArray; + activeId: string | null; + active: DetectedServer | null; + onSelect: (serverId: string) => void; + onOpen: (serverId: string) => void; + onCopy: (url: string) => void; + onStop: (serverId: string) => void; +} + +export const DetectedServersPanelView = ({ + servers, + activeId, + active, + onSelect, + onOpen, + onCopy, + onStop, +}: ViewProps) => { + if (servers.length === 0) { + return ( +
+ No servers detected yet. They'll appear here when an agent runs{" "} + dev/serve commands. +
+ ); + } + + return ( +
+
+ {servers.map((s) => ( + onSelect(s.id)} + onOpen={() => onOpen(s.id)} + onCopy={() => s.url && onCopy(s.url)} + onStop={() => onStop(s.id)} + /> + ))} +
+
+ {active ? ( + + ) : ( +
+ Select a server to view logs +
+ )} +
+
+ ); +}; + +interface Props { + threadKey: string; +} + +export const DetectedServersPanel = ({ threadKey }: Props) => { + const serversMap = useDetectedServerStore((s) => s.serversByThreadKey[threadKey]); + const activeId = useDetectedServerStore((s) => s.activeServerIdByThreadKey[threadKey] ?? null); + const setActive = useDetectedServerStore((s) => s.setActive); + + const servers = useMemo(() => (serversMap ? [...serversMap.values()] : []), [serversMap]); + const active = activeId && serversMap ? (serversMap.get(activeId) ?? null) : null; + const threadRef = parseScopedThreadKey(threadKey); + + const handleStop = async (serverId: string) => { + if (!threadRef) return; + const connection = readEnvironmentConnection(threadRef.environmentId); + if (!connection) return; + try { + const result = await connection.client.detectedServers.stop({ serverId }); + if (result.kind === "not-stoppable") { + console.info("Server managed by agent — interrupt the turn to stop it"); + } + } catch (err) { + console.error("Failed to stop detected server", err); + } + }; + + const handleCopy = (url: string) => { + void navigator.clipboard.writeText(url).catch((err) => { + console.error("Failed to copy server URL", err); + }); + }; + + const handleOpen = async (serverId: string) => { + if (!threadRef) return; + const connection = readEnvironmentConnection(threadRef.environmentId); + if (!connection) return; + try { + await connection.client.detectedServers.openInBrowser({ serverId }); + } catch (err) { + console.error("Failed to open detected server in browser", err); + } + }; + + return ( + setActive(threadKey, id)} + onOpen={(id) => void handleOpen(id)} + onCopy={handleCopy} + onStop={(id) => void handleStop(id)} + /> + ); +}; diff --git a/apps/web/src/detectedServerStore.test.ts b/apps/web/src/detectedServerStore.test.ts new file mode 100644 index 00000000000..6cb950dd63a --- /dev/null +++ b/apps/web/src/detectedServerStore.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useDetectedServerStore } from "./detectedServerStore.ts"; + +describe("detectedServerStore", () => { + beforeEach(() => useDetectedServerStore.getState().__reset()); + + it("registered adds a server to the thread map", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + } as any, + }); + expect(useDetectedServerStore.getState().serversByThreadKey["t1"]?.size).toBe(1); + }); + + it("updated mutates an existing server", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + } as any, + }); + store.handleEvent("t1", { + type: "updated", + threadId: "t1", + createdAt: "2026-05-13T00:00:01Z", + serverId: "s1", + patch: { status: "live", url: "http://localhost:5173/" }, + }); + const server = useDetectedServerStore.getState().serversByThreadKey["t1"]!.get("s1"); + expect(server?.status).toBe("live"); + expect(server?.url).toBe("http://localhost:5173/"); + }); + + it("log appends to the per-server buffer with a cap", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + } as any, + }); + store.handleEvent("t1", { + type: "log", + threadId: "t1", + createdAt: "2026-05-13T00:00:01Z", + serverId: "s1", + data: "hello\nworld\n", + }); + const buf = useDetectedServerStore.getState().logBuffersByServerId.get("s1"); + expect(buf?.snapshot()).toEqual(["hello", "world"]); + }); + + it("removed drops the server and its log buffer", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + } as any, + }); + store.handleEvent("t1", { + type: "removed", + threadId: "t1", + createdAt: "2026-05-13T00:00:02Z", + serverId: "s1", + }); + expect(useDetectedServerStore.getState().serversByThreadKey["t1"]?.size ?? 0).toBe(0); + expect(useDetectedServerStore.getState().logBuffersByServerId.has("s1")).toBe(false); + }); +}); diff --git a/apps/web/src/detectedServerStore.ts b/apps/web/src/detectedServerStore.ts new file mode 100644 index 00000000000..5ac966b53ce --- /dev/null +++ b/apps/web/src/detectedServerStore.ts @@ -0,0 +1,62 @@ +import { create } from "zustand"; +import type { DetectedServer, DetectedServerEvent } from "@ryco/contracts"; +import { LineBuffer } from "@ryco/shared/lineBuffer"; + +const MAX_LOG_LINES = 5000; + +interface State { + serversByThreadKey: Record>; + logBuffersByServerId: Map; + activeServerIdByThreadKey: Record; + handleEvent: (threadKey: string, event: DetectedServerEvent) => void; + setActive: (threadKey: string, serverId: string | null) => void; + __reset: () => void; +} + +export const useDetectedServerStore = create((set, get) => ({ + serversByThreadKey: {}, + logBuffersByServerId: new Map(), + activeServerIdByThreadKey: {}, + + handleEvent: (threadKey, event) => { + const next = { ...get().serversByThreadKey }; + const map = new Map(next[threadKey] ?? []); + const logs = new Map(get().logBuffersByServerId); + + if (event.type === "registered") { + map.set(event.server.id, event.server); + if (!logs.has(event.server.id)) { + logs.set(event.server.id, new LineBuffer({ maxLines: MAX_LOG_LINES })); + } + } else if (event.type === "updated") { + const existing = map.get(event.serverId); + if (existing) { + const cleanPatch = Object.fromEntries( + Object.entries(event.patch).filter(([, v]) => v !== undefined), + ) as Partial; + map.set(event.serverId, { ...existing, ...cleanPatch }); + } + } else if (event.type === "log") { + const buf = logs.get(event.serverId); + buf?.write(event.data); + } else if (event.type === "removed") { + map.delete(event.serverId); + logs.delete(event.serverId); + } + + next[threadKey] = map; + set({ serversByThreadKey: next, logBuffersByServerId: logs }); + }, + + setActive: (threadKey, serverId) => + set({ + activeServerIdByThreadKey: { ...get().activeServerIdByThreadKey, [threadKey]: serverId }, + }), + + __reset: () => + set({ + serversByThreadKey: {}, + logBuffersByServerId: new Map(), + activeServerIdByThreadKey: {}, + }), +})); diff --git a/apps/web/src/hooks/useDetectedServersSubscription.test.ts b/apps/web/src/hooks/useDetectedServersSubscription.test.ts new file mode 100644 index 00000000000..97a0fa33108 --- /dev/null +++ b/apps/web/src/hooks/useDetectedServersSubscription.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DetectedServerEvent, EnvironmentId, ThreadId } from "@ryco/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@ryco/client-runtime"; +import { subscribeDetectedServers } from "./useDetectedServersSubscription.ts"; + +interface FakeConnection { + client: { + detectedServers: { + onEvent: ReturnType; + }; + }; +} + +const makeFakeConnection = () => { + const unsub = vi.fn(); + const onEvent = vi.fn().mockReturnValue(unsub); + const conn = { + client: { detectedServers: { onEvent } }, + } as unknown as FakeConnection; + return { conn, onEvent, unsub }; +}; + +const keyFor = (envId: string, threadId: string): string => + scopedThreadKey(scopeThreadRef(envId as EnvironmentId, threadId as ThreadId)); + +describe("subscribeDetectedServers", () => { + it("opens a subscription scoped to the given thread", () => { + const { conn, onEvent } = makeFakeConnection(); + const dispatch = vi.fn(); + subscribeDetectedServers("env-A", "thread-1", dispatch, () => conn as never); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0]![0]).toEqual({ threadId: "thread-1" }); + }); + + it("dispatches events with the scoped thread key", () => { + const { conn, onEvent } = makeFakeConnection(); + const dispatch = vi.fn(); + subscribeDetectedServers("env-A", "thread-1", dispatch, () => conn as never); + const handler = onEvent.mock.calls[0]![1] as (e: DetectedServerEvent) => void; + const event: DetectedServerEvent = { + type: "removed", + threadId: "thread-1", + serverId: "s-1", + createdAt: new Date().toISOString(), + }; + handler(event); + expect(dispatch).toHaveBeenCalledWith(keyFor("env-A", "thread-1"), event); + }); + + it("returns an unsubscribe function which the caller invokes when the thread changes", () => { + const { conn, onEvent, unsub } = makeFakeConnection(); + const dispatch = vi.fn(); + + // Mount A + const unsubA = subscribeDetectedServers("env-A", "thread-A", dispatch, () => conn as never); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0]![0]).toEqual({ threadId: "thread-A" }); + + // Switch to B: caller invokes the previous unsubscribe and re-subscribes. + unsubA?.(); + const unsubB = subscribeDetectedServers("env-A", "thread-B", dispatch, () => conn as never); + + expect(unsub).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[1]![0]).toEqual({ threadId: "thread-B" }); + expect(typeof unsubB).toBe("function"); + }); + + it("returns undefined and does not call onEvent when environmentId or threadId is missing", () => { + const { conn, onEvent } = makeFakeConnection(); + const dispatch = vi.fn(); + expect( + subscribeDetectedServers(null, "thread-A", dispatch, () => conn as never), + ).toBeUndefined(); + expect(subscribeDetectedServers("env-A", null, dispatch, () => conn as never)).toBeUndefined(); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("returns undefined when the connection lookup yields null", () => { + const dispatch = vi.fn(); + const result = subscribeDetectedServers("env-A", "thread-A", dispatch, () => null); + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/web/src/hooks/useDetectedServersSubscription.ts b/apps/web/src/hooks/useDetectedServersSubscription.ts new file mode 100644 index 00000000000..12676727d83 --- /dev/null +++ b/apps/web/src/hooks/useDetectedServersSubscription.ts @@ -0,0 +1,58 @@ +import { useEffect } from "react"; +import type { DetectedServerEvent, EnvironmentId, ThreadId } from "@ryco/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@ryco/client-runtime"; +import { readEnvironmentConnection } from "../environments/runtime/service.ts"; +import { useDetectedServerStore } from "../detectedServerStore.ts"; + +interface ConnectionLike { + client: { + detectedServers: { + onEvent: ( + input: { threadId: string }, + listener: (event: DetectedServerEvent) => void, + ) => () => void; + }; + }; +} + +/** + * Pure subscription helper extracted for unit testing. Returns the unsubscribe + * function (or `undefined` if no subscription was opened). + */ +export const subscribeDetectedServers = ( + environmentId: string | null, + threadId: string | null, + dispatch: (threadKey: string, event: DetectedServerEvent) => void, + connect: (envId: string) => ConnectionLike | null, +): (() => void) | undefined => { + if (!environmentId || !threadId) return undefined; + const connection = connect(environmentId); + if (!connection) return undefined; + const threadKey = scopedThreadKey( + scopeThreadRef(environmentId as EnvironmentId, threadId as ThreadId), + ); + return connection.client.detectedServers.onEvent({ threadId }, (event) => { + dispatch(threadKey, event); + }); +}; + +/** + * Subscribes to detected-server events for the active server-thread and + * dispatches them into useDetectedServerStore. Tearing down: when the active + * thread (environmentId or threadId) changes, the previous subscription is + * disposed and a fresh one opened on the new thread, so events from a stale + * thread cannot leak into the new thread's store entry. + */ +export const useDetectedServersSubscription = ( + environmentId: string | null, + threadId: string | null, +): void => { + useEffect(() => { + return subscribeDetectedServers( + environmentId, + threadId, + (key, event) => useDetectedServerStore.getState().handleEvent(key, event), + (envId) => readEnvironmentConnection(envId as EnvironmentId) as ConnectionLike | null, + ); + }, [environmentId, threadId]); +}; diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 49eea22832b..205499dd2a7 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -209,6 +209,11 @@ export interface WsRpcClient { readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; + readonly detectedServers: { + readonly onEvent: RpcInputStreamMethod; + readonly stop: RpcUnaryMethod; + readonly openInBrowser: RpcUnaryMethod; + }; } export function createWsRpcClient(transport: WsTransport): WsRpcClient { @@ -443,5 +448,16 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread }, ), }, + detectedServers: { + onEvent: (input, listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeDetectedServerEvents](input), + listener, + { ...options, tag: WS_METHODS.subscribeDetectedServerEvents }, + ), + stop: (input) => transport.request((client) => client[WS_METHODS.detectedServersStop](input)), + openInBrowser: (input) => + transport.request((client) => client[WS_METHODS.detectedServersOpenInBrowser](input)), + }, }; } diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index e8479ec64d9..0c54c682953 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -18,6 +18,8 @@ import { type ThreadTerminalGroup, } from "./types"; +export type DrawerKind = "terminals" | "servers"; + interface ThreadTerminalState { terminalOpen: boolean; terminalHeight: number; @@ -566,6 +568,7 @@ export function selectTerminalEventEntries( interface TerminalStateStoreState { terminalStateByThreadKey: Record; terminalLaunchContextByThreadKey: Record; + terminalDrawerKindByThreadKey: Record; terminalEventEntriesByKey: Record>; nextTerminalEventId: number; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; @@ -591,6 +594,7 @@ interface TerminalStateStoreState { ) => void; recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; + setTerminalDrawerKind: (threadKey: string, kind: DrawerKind) => void; clearTerminalState: (threadRef: ScopedThreadRef) => void; removeTerminalState: (threadRef: ScopedThreadRef) => void; removeOrphanedTerminalStates: (activeThreadKeys: Set) => void; @@ -621,6 +625,7 @@ export const useTerminalStateStore = create()( return { terminalStateByThreadKey: {}, terminalLaunchContextByThreadKey: {}, + terminalDrawerKindByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, setTerminalOpen: (threadRef, open) => @@ -734,6 +739,22 @@ export const useTerminalStateStore = create()( ...nextEventState, }; }), + setTerminalDrawerKind: (threadKey, kind) => + set((state) => { + const current = state.terminalDrawerKindByThreadKey[threadKey]; + if (current === kind) return state; + if (kind === "terminals") { + if (current === undefined) return state; + const { [threadKey]: _removed, ...rest } = state.terminalDrawerKindByThreadKey; + return { terminalDrawerKindByThreadKey: rest }; + } + return { + terminalDrawerKindByThreadKey: { + ...state.terminalDrawerKindByThreadKey, + [threadKey]: kind, + }, + }; + }), clearTerminalState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); diff --git a/bun.lock b/bun.lock index 2cc4e277b87..f86664ec255 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", + "pidtree": "^0.6.0", "sharp": "^0.34.5", }, "devDependencies": { @@ -1694,6 +1695,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], diff --git a/docs/superpowers/plans/2026-05-13-auto-detect-agent-servers.md b/docs/superpowers/plans/2026-05-13-auto-detect-agent-servers.md new file mode 100644 index 00000000000..c40741aceec --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-auto-detect-agent-servers.md @@ -0,0 +1,3692 @@ +# Auto-Detect Agent-Spawned Servers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auto-detect dev servers (Vite, Next, Nuxt, Astro, Remix, Wrangler, Vitest UI, Storybook, HTTP/SSE MCP, generic Express) spawned by agents or local PTYs, present them in a "Servers" tab inside the existing terminal drawer with live xterm.js log tailing, and show a count badge in the toolbar. + +**Architecture:** A new `apps/server/src/detectedServers/` Effect module runs a 4-stage detection pipeline (argv hint → stdout regex → OS socket probe → HTTP liveness heartbeat), feeds an in-memory registry keyed by `(threadId, serverId)`, and publishes events on a new `detectedServers.event` WS push channel. Read-only taps in `CodexSessionRuntime`, `AcpSessionRuntime`, and `terminal/Layers/Manager.ts` feed the detector without changing existing behavior. Web side adds a Zustand slice, a toolbar badge, and a kind-tabset inside `ThreadTerminalDrawer` with per-server xterm.js mounts. + +**Tech Stack:** TypeScript, Effect 4.0 (Services/Layers/Schema), Vitest, node-pty, xterm.js v6, Zustand, TanStack Router. New runtime dep: `pidtree`. + +**Spec:** `docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md` + +--- + +## File structure + +``` +apps/server/src/detectedServers/ + Services/DetectedServerRegistry.ts new + Layers/Registry.ts new + Layers/ArgvHinter.ts new + Layers/StdoutSniffer.ts new + Layers/SocketProbe.ts new + Layers/SocketProbe.Linux.ts new + Layers/SocketProbe.Darwin.ts new + Layers/SocketProbe.Windows.ts new + Layers/LivenessHeartbeat.ts new + Layers/DetectedServersIngress.ts new + __fixtures__/stdout/{vite,next,nuxt,astro, + remix,wrangler,webpack,express}.txt new + __fixtures__/proc/{tcp,tcp6,fd-snapshot}.txt new + ArgvHinter.test.ts new + StdoutSniffer.test.ts new + Registry.test.ts new + SocketProbe.Linux.test.ts new + SocketProbe.Darwin.test.ts new + SocketProbe.Windows.test.ts new + +apps/server/src/serverLayers.ts modify (+ ingress) +apps/server/src/wsServer.ts modify (+ RPC handlers, push) +apps/server/src/provider/Layers/CodexSessionRuntime.ts modify (+ tap) +apps/server/src/provider/acp/AcpSessionRuntime.ts modify (+ tap) +apps/server/src/terminal/Layers/Manager.ts modify (+ tap) + +apps/server/integration/ + detectedServersPty.integration.test.ts new + detectedServersCodex.integration.test.ts new + +packages/contracts/src/detectedServers.ts new +packages/contracts/src/rpc.ts modify (+ 3 RPCs) +packages/contracts/src/ws.ts modify (+ 1 channel) +packages/contracts/src/index.ts modify (+ re-exports) + +packages/shared/src/lineBuffer.ts new +packages/shared/src/lineBuffer.test.ts new +packages/shared/package.json modify (+ subpath export) + +apps/web/src/detectedServerStore.ts new +apps/web/src/detectedServerStore.test.ts new +apps/web/src/rpc/wsRpcClient.ts modify (+ subscribe) +apps/web/src/terminalStateStore.ts modify (+ drawerKind field) +apps/web/src/components/BranchToolbar.tsx modify (+ badge slot) +apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx new +apps/web/src/components/BranchToolbar/DetectedServersBadge.test.tsx new +apps/web/src/components/ThreadTerminalDrawer.tsx modify (+ kind tabset) +apps/web/src/components/detectedServers/DetectedServersPanel.tsx new +apps/web/src/components/detectedServers/DetectedServerRow.tsx new +apps/web/src/components/detectedServers/DetectedServerLogView.tsx new + +package.json modify (+ pidtree dep) +``` + +--- + +## Phase 1 — Foundations + +Establish the schema, shared utilities, and dependency before any logic. + +### Task 1: Add `pidtree` runtime dependency + +**Files:** + +- Modify: `package.json` +- Modify: `apps/server/package.json` + +- [ ] **Step 1: Add `pidtree` to `apps/server/package.json` dependencies** + +```bash +cd apps/server && bun add pidtree +``` + +Expected: `pidtree` appears in `apps/server/package.json` dependencies. + +- [ ] **Step 2: Verify lockfile resolves** + +Run: `bun install` +Expected: no errors; `bun.lock` updated. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/package.json bun.lock +git commit -m "Add pidtree dependency for process-tree socket probing" +``` + +--- + +### Task 2: Add `LineBuffer` shared utility + +**Files:** + +- Create: `packages/shared/src/lineBuffer.ts` +- Create: `packages/shared/src/lineBuffer.test.ts` +- Modify: `packages/shared/package.json` (add subpath export) + +- [ ] **Step 1: Write the failing test** + +Create `packages/shared/src/lineBuffer.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { LineBuffer } from "./lineBuffer.ts"; + +describe("LineBuffer", () => { + it("appends chunks and yields complete lines on flush", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("hello\nworld\n"); + expect(buf.snapshot()).toEqual(["hello", "world"]); + }); + + it("retains incomplete trailing fragment until flush", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("hello\nwor"); + buf.write("ld\n"); + expect(buf.snapshot()).toEqual(["hello", "world"]); + }); + + it("trims head when maxLines exceeded", () => { + const buf = new LineBuffer({ maxLines: 2 }); + buf.write("a\nb\nc\nd\n"); + expect(buf.snapshot()).toEqual(["c", "d"]); + }); + + it("clear() empties the buffer", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("a\nb\n"); + buf.clear(); + expect(buf.snapshot()).toEqual([]); + }); + + it("snapshot() returns a defensive copy", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("a\n"); + const snap = buf.snapshot(); + snap.push("mutation"); + expect(buf.snapshot()).toEqual(["a"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test --filter @ryco/shared -- lineBuffer` +Expected: FAIL, "Cannot find module './lineBuffer.ts'". + +- [ ] **Step 3: Implement `LineBuffer`** + +Create `packages/shared/src/lineBuffer.ts`: + +```ts +export interface LineBufferOptions { + readonly maxLines: number; +} + +/** + * Rolling line buffer. Appends arbitrary chunks, splits on \n, retains an + * incomplete trailing fragment for the next write, and trims from the head + * when maxLines is exceeded. + */ +export class LineBuffer { + private lines: string[] = []; + private fragment = ""; + private readonly maxLines: number; + + constructor(options: LineBufferOptions) { + this.maxLines = options.maxLines; + } + + write(chunk: string): void { + if (chunk.length === 0) return; + const combined = this.fragment + chunk; + const parts = combined.split("\n"); + this.fragment = parts.pop() ?? ""; + if (parts.length === 0) return; + this.lines.push(...parts); + if (this.lines.length > this.maxLines) { + this.lines.splice(0, this.lines.length - this.maxLines); + } + } + + snapshot(): string[] { + return this.lines.slice(); + } + + clear(): void { + this.lines = []; + this.fragment = ""; + } +} +``` + +- [ ] **Step 4: Add subpath export to `packages/shared/package.json`** + +In the `exports` field, add: + +```json +"./lineBuffer": { + "types": "./src/lineBuffer.ts", + "default": "./src/lineBuffer.ts" +} +``` + +- [ ] **Step 5: Run tests to verify pass** + +Run: `bun run test --filter @ryco/shared -- lineBuffer` +Expected: all 5 tests PASS. + +- [ ] **Step 6: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add packages/shared/src/lineBuffer.ts packages/shared/src/lineBuffer.test.ts packages/shared/package.json +git commit -m "Add LineBuffer shared utility for rolling line storage" +``` + +--- + +### Task 3: Define `detectedServers` contracts schema + +**Files:** + +- Create: `packages/contracts/src/detectedServers.ts` +- Modify: `packages/contracts/src/index.ts` + +- [ ] **Step 1: Create the schema file** + +Create `packages/contracts/src/detectedServers.ts`: + +```ts +import { Schema } from "effect"; + +export const ServerStatus = Schema.Literals([ + "predicted", + "candidate", + "confirmed", + "live", + "restarting", + "exited", + "crashed", +]); +export type ServerStatus = typeof ServerStatus.Type; + +export const ServerSource = Schema.Literals(["codex", "acp", "pty"]); +export type ServerSource = typeof ServerSource.Type; + +export const ServerFramework = Schema.Literals([ + "vite", + "next", + "nuxt", + "remix", + "astro", + "wrangler", + "webpack", + "vitest-ui", + "storybook", + "mcp-http", + "express", + "unknown", +]); +export type ServerFramework = typeof ServerFramework.Type; + +export const ExitReason = Schema.Literals(["stopped", "crashed", "lost-socket"]); +export type ExitReason = typeof ExitReason.Type; + +export const DetectedServer = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty()), + threadId: Schema.String.check(Schema.isNonEmpty()), + source: ServerSource, + framework: ServerFramework, + status: ServerStatus, + url: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + host: Schema.optional(Schema.String), + pid: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + argv: Schema.optional(Schema.Array(Schema.String)), + cwd: Schema.optional(Schema.String), + startedAt: Schema.Date, + liveAt: Schema.optional(Schema.Date), + lastSeenAt: Schema.Date, + exitedAt: Schema.optional(Schema.Date), + exitReason: Schema.optional(ExitReason), +}); +export type DetectedServer = typeof DetectedServer.Type; + +const DetectedServerEventBase = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), + createdAt: Schema.String, +}); + +const RegisteredEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("registered"), + server: DetectedServer, +}); + +const UpdatedEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("updated"), + serverId: Schema.String.check(Schema.isNonEmpty()), + patch: Schema.Struct({ + status: Schema.optional(ServerStatus), + framework: Schema.optional(ServerFramework), + url: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int), + host: Schema.optional(Schema.String), + pid: Schema.optional(Schema.Int), + liveAt: Schema.optional(Schema.Date), + lastSeenAt: Schema.optional(Schema.Date), + exitedAt: Schema.optional(Schema.Date), + exitReason: Schema.optional(ExitReason), + }), +}); + +const LogEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("log"), + serverId: Schema.String.check(Schema.isNonEmpty()), + data: Schema.String, +}); + +const RemovedEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("removed"), + serverId: Schema.String.check(Schema.isNonEmpty()), +}); + +export const DetectedServerEvent = Schema.Union([ + RegisteredEvent, + UpdatedEvent, + LogEvent, + RemovedEvent, +]); +export type DetectedServerEvent = typeof DetectedServerEvent.Type; + +export const DetectedServerStopInput = Schema.Struct({ + serverId: Schema.String.check(Schema.isNonEmpty()), +}); +export type DetectedServerStopInput = typeof DetectedServerStopInput.Type; + +export const DetectedServerStopResult = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("stopped") }), + Schema.Struct({ + kind: Schema.Literal("not-stoppable"), + hint: Schema.Literal("interrupt-turn"), + }), +]); +export type DetectedServerStopResult = typeof DetectedServerStopResult.Type; + +export const DetectedServerOpenInBrowserInput = Schema.Struct({ + serverId: Schema.String.check(Schema.isNonEmpty()), +}); +export type DetectedServerOpenInBrowserInput = typeof DetectedServerOpenInBrowserInput.Type; + +export const SubscribeDetectedServerEventsInput = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), +}); +export type SubscribeDetectedServerEventsInput = typeof SubscribeDetectedServerEventsInput.Type; +``` + +- [ ] **Step 2: Re-export from package index** + +Modify `packages/contracts/src/index.ts` — add: + +```ts +export * from "./detectedServers.ts"; +``` + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/contracts/src/detectedServers.ts packages/contracts/src/index.ts +git commit -m "Add DetectedServer contracts schema" +``` + +--- + +### Task 4: Add `detectedServers.event` push channel + RPCs + +**Files:** + +- Modify: `packages/contracts/src/ws.ts` +- Modify: `packages/contracts/src/rpc.ts` + +- [ ] **Step 1: Inspect the existing push channel definition** + +Run: `grep -n "terminal.event\|server.welcome\|orchestration.domainEvent" packages/contracts/src/ws.ts` +Expected: matches showing the channel union and per-channel envelope shapes. + +- [ ] **Step 2: Add `detectedServers.event` channel** + +In `packages/contracts/src/ws.ts`, locate the discriminated union of push channels (search for `terminal.event`). Add an entry mirroring the terminal channel: + +```ts +const DetectedServersEventEnvelope = Schema.Struct({ + channel: Schema.Literal("detectedServers.event"), + sequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + data: DetectedServerEvent, +}); +``` + +…and add `DetectedServersEventEnvelope` to the push-channel union. + +Import `DetectedServerEvent` from `./detectedServers.ts` at the top of `ws.ts`. + +- [ ] **Step 3: Add RPC method definitions** + +In `packages/contracts/src/rpc.ts`, locate the existing RPC registry (search for `subscribeTerminalEvents`). Add three entries: + +```ts +subscribeDetectedServerEvents: { + input: SubscribeDetectedServerEventsInput, + output: DetectedServerEvent, + stream: true, +}, +"detectedServers.stop": { + input: DetectedServerStopInput, + output: DetectedServerStopResult, + stream: false, +}, +"detectedServers.openInBrowser": { + input: DetectedServerOpenInBrowserInput, + output: Schema.Struct({ ok: Schema.Boolean }), + stream: false, +}, +``` + +Adapt the exact registry shape to match the existing pattern in `rpc.ts`. + +Import the four input/output schemas from `./detectedServers.ts`. + +- [ ] **Step 4: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/ws.ts packages/contracts/src/rpc.ts +git commit -m "Wire detectedServers WS channel and RPC methods into contracts" +``` + +--- + +## Phase 2 — Detection services (pure, no I/O) + +Each service is a self-contained Effect Service with TDD. + +### Task 5: Implement `ArgvHinter` + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/ArgvHinter.ts` +- Create: `apps/server/src/detectedServers/ArgvHinter.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/server/src/detectedServers/ArgvHinter.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { hintFromArgv } from "./Layers/ArgvHinter.ts"; + +describe("ArgvHinter.hintFromArgv", () => { + const cases: ReadonlyArray<{ + name: string; + argv: string[]; + expected: { framework: string; isLikelyServer: boolean }; + }> = [ + { name: "vite", argv: ["vite"], expected: { framework: "vite", isLikelyServer: true } }, + { + name: "next dev", + argv: ["next", "dev"], + expected: { framework: "next", isLikelyServer: true }, + }, + { + name: "nuxt dev", + argv: ["nuxt", "dev"], + expected: { framework: "nuxt", isLikelyServer: true }, + }, + { + name: "astro dev", + argv: ["astro", "dev"], + expected: { framework: "astro", isLikelyServer: true }, + }, + { + name: "remix dev", + argv: ["remix", "dev"], + expected: { framework: "remix", isLikelyServer: true }, + }, + { + name: "wrangler dev", + argv: ["wrangler", "dev"], + expected: { framework: "wrangler", isLikelyServer: true }, + }, + { + name: "vitest --ui", + argv: ["vitest", "--ui"], + expected: { framework: "vitest-ui", isLikelyServer: true }, + }, + { + name: "storybook dev", + argv: ["storybook", "dev"], + expected: { framework: "storybook", isLikelyServer: true }, + }, + { + name: "vite build", + argv: ["vite", "build"], + expected: { framework: "vite", isLikelyServer: false }, + }, + { + name: "vitest run", + argv: ["vitest", "run"], + expected: { framework: "unknown", isLikelyServer: false }, + }, + { name: "tsc", argv: ["tsc"], expected: { framework: "unknown", isLikelyServer: false } }, + { name: "eslint", argv: ["eslint"], expected: { framework: "unknown", isLikelyServer: false } }, + { + name: "unknown serve", + argv: ["foo", "serve"], + expected: { framework: "unknown", isLikelyServer: true }, + }, + ]; + + for (const c of cases) { + it(`hints ${c.name}`, () => { + const got = hintFromArgv(c.argv, undefined); + expect(got).toEqual(c.expected); + }); + } + + it("re-scans package.json scripts.dev for indirect invocations", () => { + const got = hintFromArgv(["npm", "run", "dev"], { scripts: { dev: "vite" } }); + expect(got).toEqual({ framework: "vite", isLikelyServer: true }); + }); + + it("treats npm run build as build, not server", () => { + const got = hintFromArgv(["npm", "run", "build"], { scripts: { build: "vite build" } }); + expect(got).toEqual({ framework: "vite", isLikelyServer: false }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test --filter @ryco/server -- ArgvHinter` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement `ArgvHinter`** + +Create `apps/server/src/detectedServers/Layers/ArgvHinter.ts`: + +```ts +import type { ServerFramework } from "@ryco/contracts"; + +export interface ArgvHint { + framework: ServerFramework; + isLikelyServer: boolean; +} + +export interface PackageJsonShape { + scripts?: Record; +} + +const DENY_TOKENS = new Set([ + "build", + "test", + "tsc", + "eslint", + "prettier", + "playwright", + "typecheck", + "lint", + "fmt", +]); + +const SERVER_TRIGGER_TOKENS = new Set(["dev", "serve", "start", "watch"]); + +const FRAMEWORK_TOKEN_MAP: ReadonlyArray = [ + ["vite", "vite"], + ["next", "next"], + ["nuxt", "nuxt"], + ["nuxi", "nuxt"], + ["astro", "astro"], + ["remix", "remix"], + ["wrangler", "wrangler"], + ["storybook", "storybook"], + ["webpack-dev-server", "webpack"], +]; + +const PACKAGE_RUNNERS = new Set(["npm", "pnpm", "yarn", "bun"]); + +export const hintFromArgv = ( + argv: ReadonlyArray, + pkg: PackageJsonShape | undefined, +): ArgvHint => { + const tokens = argv.map((t) => t.toLowerCase()); + + // Indirect invocation: run + if ( + tokens.length >= 3 && + PACKAGE_RUNNERS.has(tokens[0]!) && + tokens[1] === "run" && + pkg?.scripts?.[tokens[2]!] + ) { + const inner = pkg.scripts[tokens[2]!]!.split(/\s+/).filter(Boolean); + return hintFromArgv(inner, undefined); + } + + // Shortcut: dev / serve / start / watch (no explicit "run" keyword) + if ( + tokens.length >= 2 && + PACKAGE_RUNNERS.has(tokens[0]!) && + SERVER_TRIGGER_TOKENS.has(tokens[1]!) + ) { + if (pkg?.scripts?.[tokens[1]!]) { + const inner = pkg.scripts[tokens[1]!]!.split(/\s+/).filter(Boolean); + return hintFromArgv(inner, undefined); + } + } + + // Special-case: vitest --ui (UI mode is a server; vitest run is not) + if (tokens[0] === "vitest" && tokens.includes("--ui")) { + return { framework: "vitest-ui", isLikelyServer: true }; + } + + // Framework token match + for (const [tok, fw] of FRAMEWORK_TOKEN_MAP) { + if (tokens[0]?.endsWith(tok) || tokens.includes(tok)) { + const hasDeny = tokens.some((t) => DENY_TOKENS.has(t)); + if (hasDeny) return { framework: fw, isLikelyServer: false }; + return { framework: fw, isLikelyServer: true }; + } + } + + // Generic server trigger tokens + const hasServerTrigger = tokens.some((t) => SERVER_TRIGGER_TOKENS.has(t)); + const hasDeny = tokens.some((t) => DENY_TOKENS.has(t)); + if (hasServerTrigger && !hasDeny) { + return { framework: "unknown", isLikelyServer: true }; + } + + return { framework: "unknown", isLikelyServer: false }; +}; +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun run test --filter @ryco/server -- ArgvHinter` +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/ArgvHinter.ts apps/server/src/detectedServers/ArgvHinter.test.ts +git commit -m "Add ArgvHinter for framework detection from spawn argv" +``` + +--- + +### Task 6: Implement `StdoutSniffer` (regex + ANSI strip) + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/StdoutSniffer.ts` +- Create: `apps/server/src/detectedServers/StdoutSniffer.test.ts` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/vite.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/next.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/nuxt.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/astro.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/remix.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/wrangler.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/webpack.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/stdout/express.txt` + +- [ ] **Step 1: Create the eight fixture files** + +Each fixture is real captured stdout from running the framework. Use simplified, ANSI-rich captures. Examples: + +`__fixtures__/stdout/vite.txt`: + +``` + VITE v5.0.10 ready in 312 ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +``` + +`__fixtures__/stdout/next.txt`: + +``` + ▲ Next.js 14.0.4 + - Local: http://localhost:3000 + - Environments: .env + + ✓ Ready in 1.2s +``` + +`__fixtures__/stdout/nuxt.txt`: + +``` +ℹ Vite client warmed up in 432ms +ℹ Vite server warmed up in 451ms + + Nuxt 3.10.0 with Nitro 2.8.1 + + + ➜ Local: http://localhost:3000/ + ➜ Network: use --host to expose +``` + +`__fixtures__/stdout/astro.txt`: + +``` + astro v4.0.7 ready in 145 ms + +┃ Local http://localhost:4321/ +┃ Network use --host to expose +``` + +`__fixtures__/stdout/remix.txt`: + +``` + 💿 remix dev + + info serving HTTP on http://localhost:3000 +``` + +`__fixtures__/stdout/wrangler.txt`: + +``` +⛅️ wrangler 3.20.0 + +[mf:inf] Ready on http://127.0.0.1:8787 +``` + +`__fixtures__/stdout/webpack.txt`: + +``` + [webpack-dev-server] Project is running at: + [webpack-dev-server] Loopback: http://localhost:8080/ + [webpack-dev-server] On Your Network (IPv4): http://192.168.1.42:8080/ +``` + +`__fixtures__/stdout/express.txt`: + +``` +Server listening on port 3000 +``` + +Embed ANSI sequences (`\x1b[36m`, `\x1b[0m`) liberally in the .txt files — these tests assert robustness against them. + +- [ ] **Step 2: Write the failing test** + +Create `apps/server/src/detectedServers/StdoutSniffer.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { StdoutSniffer } from "./Layers/StdoutSniffer.ts"; + +const fixturePath = (name: string) => + join(import.meta.dirname, "__fixtures__/stdout", `${name}.txt`); + +describe("StdoutSniffer", () => { + it("extracts Vite URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("vite"), "utf8")); + expect(out).toHaveLength(1); + expect(out[0]!.url).toBe("http://localhost:5173/"); + expect(out[0]!.port).toBe(5173); + expect(out[0]!.framework).toBe("vite"); + }); + + it("extracts Next URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("next"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:3000"); + expect(out[0]!.framework).toBe("next"); + }); + + it("extracts Nuxt URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("nuxt"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:3000/"); + expect(out[0]!.framework).toBe("nuxt"); + }); + + it("extracts Astro URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("astro"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:4321/"); + expect(out[0]!.framework).toBe("astro"); + }); + + it("extracts Remix URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("remix"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:3000"); + expect(out[0]!.framework).toBe("remix"); + }); + + it("extracts Wrangler URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("wrangler"), "utf8")); + expect(out[0]!.url).toBe("http://127.0.0.1:8787"); + expect(out[0]!.framework).toBe("wrangler"); + }); + + it("extracts Webpack-DevServer URL", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("webpack"), "utf8")); + expect(out[0]!.url).toBe("http://localhost:8080/"); + expect(out[0]!.framework).toBe("webpack"); + }); + + it("extracts generic Express port", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number; framework: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed(readFileSync(fixturePath("express"), "utf8")); + expect(out[0]!.port).toBe(3000); + expect(out[0]!.framework).toBe("express"); + }); + + it("assembles URLs split across chunks", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string; port: number }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed("Local: http://localho"); + sniffer.feed("st:5173/\n"); + expect(out).toHaveLength(1); + expect(out[0]!.url).toBe("http://localhost:5173/"); + }); + + it("strips ANSI before matching", () => { + const sniffer = new StdoutSniffer(); + const out: { url: string }[] = []; + sniffer.onCandidate((c) => out.push(c)); + sniffer.feed("\x1b[36m ➜ Local:\x1b[0m \x1b[1mhttp://localhost:5173/\x1b[0m\n"); + expect(out[0]!.url).toBe("http://localhost:5173/"); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bun run test --filter @ryco/server -- StdoutSniffer` +Expected: FAIL, module not found. + +- [ ] **Step 4: Implement `StdoutSniffer`** + +Create `apps/server/src/detectedServers/Layers/StdoutSniffer.ts`: + +```ts +import type { ServerFramework } from "@ryco/contracts"; + +export interface UrlCandidate { + url: string; + port: number; + host: string; + framework: ServerFramework; +} + +const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]/g; +const URL_REGEX_GENERIC = + /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::(\d+))?(?:\/\S*)?/i; + +interface FrameworkPattern { + framework: ServerFramework; + lineHint: RegExp; +} + +// Ordered specific → generic. First match wins per line. +const FRAMEWORK_PATTERNS: ReadonlyArray = [ + { framework: "vite", lineHint: /\bVITE\b|➜\s+Local:\s+http/i }, + { framework: "next", lineHint: /Next\.js|^\s*-?\s*Local:\s+http.*localhost:3000/i }, + { framework: "nuxt", lineHint: /Nuxt\s+\d|➜\s+Local:\s+http/i }, + { framework: "astro", lineHint: /astro\s+v\d|Local\s+http/i }, + { framework: "remix", lineHint: /remix dev|serving HTTP on/i }, + { framework: "wrangler", lineHint: /wrangler|\[mf:inf\] Ready on/i }, + { framework: "webpack", lineHint: /\[webpack-dev-server\] (?:Loopback|Project is running)/i }, +]; + +const PORT_ONLY_REGEX = /\b(?:listening|server (?:listening|running))\b[^\d]*?\b(\d{2,5})\b/i; + +export class StdoutSniffer { + private fragment = ""; + private listeners = new Set<(c: UrlCandidate) => void>(); + private contextLines: { framework: ServerFramework | null; lines: string[] } = { + framework: null, + lines: [], + }; + + onCandidate(cb: (c: UrlCandidate) => void): () => void { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + feed(chunk: string): void { + if (chunk.length === 0) return; + const combined = this.fragment + chunk; + const parts = combined.split("\n"); + this.fragment = parts.pop() ?? ""; + for (const raw of parts) this.consumeLine(raw); + } + + private consumeLine(raw: string): void { + const line = raw.replace(ANSI_REGEX, "").replace(/\s+/g, " ").trim(); + if (!line) return; + + // Detect framework hint from any recent line; carry it forward + for (const pattern of FRAMEWORK_PATTERNS) { + if (pattern.lineHint.test(line)) { + this.contextLines.framework = pattern.framework; + break; + } + } + + // Try to extract URL on this line + const urlMatch = line.match(URL_REGEX_GENERIC); + if (urlMatch) { + const url = urlMatch[0]; + const host = this.extractHost(url); + const port = this.extractPort(url); + if (port !== null) { + this.emit({ + url, + port, + host, + framework: this.contextLines.framework ?? "unknown", + }); + return; + } + } + + // Fallback: port-only Express-style line + const portMatch = line.match(PORT_ONLY_REGEX); + if (portMatch) { + const port = Number.parseInt(portMatch[1]!, 10); + this.emit({ + url: `http://localhost:${port}`, + port, + host: "localhost", + framework: this.contextLines.framework ?? "express", + }); + } + } + + private extractHost(url: string): string { + const m = url.match(/https?:\/\/(\[[^\]]+\]|[^/:]+)/i); + return m?.[1] ?? "localhost"; + } + + private extractPort(url: string): number | null { + const m = url.match(/:(\d+)(?:\/|$)/); + if (m) return Number.parseInt(m[1]!, 10); + if (url.startsWith("https://")) return 443; + if (url.startsWith("http://")) return 80; + return null; + } + + private emit(c: UrlCandidate): void { + for (const l of this.listeners) l(c); + } +} +``` + +- [ ] **Step 5: Run tests to verify pass** + +Run: `bun run test --filter @ryco/server -- StdoutSniffer` +Expected: all 10 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/StdoutSniffer.ts apps/server/src/detectedServers/StdoutSniffer.test.ts apps/server/src/detectedServers/__fixtures__/stdout/ +git commit -m "Add StdoutSniffer for framework URL extraction from spawn output" +``` + +--- + +### Task 7: Implement `Registry` state machine + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/Registry.ts` +- Create: `apps/server/src/detectedServers/Registry.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/server/src/detectedServers/Registry.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { Registry } from "./Layers/Registry.ts"; +import type { DetectedServerEvent } from "@ryco/contracts"; + +const collectEvents = (registry: Registry) => { + const events: DetectedServerEvent[] = []; + registry.subscribe("thread-1", (e) => events.push(e)); + return events; +}; + +describe("Registry", () => { + it("registers a predicted server and emits a registered event", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { + framework: "vite", + status: "predicted", + pid: 42, + port: 5173, + argv: ["vite"], + cwd: "/work", + }, + }); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("registered"); + if (events[0]!.type === "registered") { + expect(events[0]!.server.status).toBe("predicted"); + expect(events[0]!.server.framework).toBe("vite"); + } + }); + + it("emits updated on subsequent transitions", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "predicted", pid: 42, port: 5173 }, + }); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { status: "candidate", url: "http://localhost:5173/" }, + }); + expect(events[1]!.type).toBe("updated"); + }); + + it("rejects illegal transition (live → predicted)", () => { + const r = new Registry(); + collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "live", pid: 42, port: 5173 }, + }); + expect(() => + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { status: "predicted" }, + }), + ).toThrow(/illegal transition/i); + }); + + it("treats same identityKey as restart, not new server", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "live", pid: 42, port: 5173 }, + }); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { status: "restarting" }, + }); + expect(r.getCurrent("thread-1").length).toBe(1); + expect(events.filter((e) => e.type === "registered").length).toBe(1); + }); + + it("getCurrent returns servers for a thread only", () => { + const r = new Registry(); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "predicted", pid: 42, port: 5173 }, + }); + r.registerOrUpdate({ + threadId: "thread-2", + source: "pty", + identityKey: "thread-2::99::3000", + patch: { framework: "next", status: "predicted", pid: 99, port: 3000 }, + }); + expect(r.getCurrent("thread-1").length).toBe(1); + expect(r.getCurrent("thread-2").length).toBe(1); + }); + + it("publishLog emits log events to subscribers of the matching thread", () => { + const r = new Registry(); + const events = collectEvents(r); + r.registerOrUpdate({ + threadId: "thread-1", + source: "pty", + identityKey: "thread-1::42::5173", + patch: { framework: "vite", status: "predicted", pid: 42, port: 5173 }, + }); + const serverId = r.getCurrent("thread-1")[0]!.id; + r.publishLog(serverId, "hello\n"); + const log = events.find((e) => e.type === "log"); + expect(log).toBeDefined(); + if (log?.type === "log") { + expect(log.data).toBe("hello\n"); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test --filter @ryco/server -- Registry` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement `Registry`** + +Create `apps/server/src/detectedServers/Layers/Registry.ts`: + +```ts +import { randomUUID } from "node:crypto"; +import type { + DetectedServer, + DetectedServerEvent, + ServerFramework, + ServerSource, + ServerStatus, + ExitReason, +} from "@ryco/contracts"; + +const ALLOWED_TRANSITIONS: Record> = { + predicted: ["candidate", "confirmed", "exited", "crashed"], + candidate: ["confirmed", "live", "exited", "crashed"], + confirmed: ["live", "exited", "crashed"], + live: ["restarting", "exited", "crashed"], + restarting: ["live", "exited", "crashed"], + exited: [], + crashed: [], +}; + +export interface RegistryPatch { + framework?: ServerFramework; + status?: ServerStatus; + url?: string; + port?: number; + host?: string; + pid?: number; + argv?: ReadonlyArray; + cwd?: string; + liveAt?: Date; + lastSeenAt?: Date; + exitedAt?: Date; + exitReason?: ExitReason; +} + +export interface RegistryRegisterInput { + threadId: string; + source: ServerSource; + identityKey: string; + patch: RegistryPatch; +} + +type Listener = (e: DetectedServerEvent) => void; + +export class Registry { + private byThread = new Map>(); + private idByIdentity = new Map(); + private listeners = new Map>(); + + subscribe(threadId: string, listener: Listener): () => void { + const set = this.listeners.get(threadId) ?? new Set(); + set.add(listener); + this.listeners.set(threadId, set); + return () => { + const cur = this.listeners.get(threadId); + cur?.delete(listener); + }; + } + + getCurrent(threadId: string): DetectedServer[] { + const m = this.byThread.get(threadId); + return m ? [...m.values()] : []; + } + + findById(serverId: string): DetectedServer | undefined { + for (const m of this.byThread.values()) { + const s = m.get(serverId); + if (s) return s; + } + return undefined; + } + + registerOrUpdate(input: RegistryRegisterInput): DetectedServer { + const existingId = this.idByIdentity.get(input.identityKey); + if (existingId) return this.updateExisting(input, existingId); + return this.registerNew(input); + } + + publishLog(serverId: string, data: string): void { + const threadId = this.findThreadOf(serverId); + if (!threadId) return; + this.publish(threadId, { + type: "log", + threadId, + serverId, + data, + createdAt: new Date().toISOString(), + }); + } + + remove(serverId: string): void { + const threadId = this.findThreadOf(serverId); + if (!threadId) return; + const m = this.byThread.get(threadId); + const server = m?.get(serverId); + if (!server || !m) return; + m.delete(serverId); + this.idByIdentity.forEach((id, key) => { + if (id === serverId) this.idByIdentity.delete(key); + }); + this.publish(threadId, { + type: "removed", + threadId, + serverId, + createdAt: new Date().toISOString(), + }); + } + + private registerNew(input: RegistryRegisterInput): DetectedServer { + const id = randomUUID(); + const now = new Date(); + const status = input.patch.status ?? "predicted"; + const server: DetectedServer = { + id, + threadId: input.threadId, + source: input.source, + framework: input.patch.framework ?? "unknown", + status, + url: input.patch.url, + port: input.patch.port, + host: input.patch.host, + pid: input.patch.pid, + argv: input.patch.argv, + cwd: input.patch.cwd, + startedAt: now, + liveAt: input.patch.liveAt, + lastSeenAt: now, + exitedAt: input.patch.exitedAt, + exitReason: input.patch.exitReason, + }; + const m = this.byThread.get(input.threadId) ?? new Map(); + m.set(id, server); + this.byThread.set(input.threadId, m); + this.idByIdentity.set(input.identityKey, id); + this.publish(input.threadId, { + type: "registered", + threadId: input.threadId, + server, + createdAt: now.toISOString(), + }); + return server; + } + + private updateExisting(input: RegistryRegisterInput, serverId: string): DetectedServer { + const m = this.byThread.get(input.threadId); + const cur = m?.get(serverId); + if (!cur || !m) throw new Error(`Registry inconsistency: missing server ${serverId}`); + + if (input.patch.status && input.patch.status !== cur.status) { + const legal = ALLOWED_TRANSITIONS[cur.status]; + if (!legal.includes(input.patch.status)) { + throw new Error(`illegal transition ${cur.status} → ${input.patch.status} for ${serverId}`); + } + } + + const next: DetectedServer = { + ...cur, + ...input.patch, + lastSeenAt: input.patch.lastSeenAt ?? new Date(), + }; + m.set(serverId, next); + this.publish(input.threadId, { + type: "updated", + threadId: input.threadId, + serverId, + patch: input.patch, + createdAt: new Date().toISOString(), + }); + return next; + } + + private findThreadOf(serverId: string): string | undefined { + for (const [threadId, m] of this.byThread) { + if (m.has(serverId)) return threadId; + } + return undefined; + } + + private publish(threadId: string, event: DetectedServerEvent): void { + const set = this.listeners.get(threadId); + if (!set) return; + for (const l of set) l(event); + } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun run test --filter @ryco/server -- Registry` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/Registry.ts apps/server/src/detectedServers/Registry.test.ts +git commit -m "Add Registry with state-machine validation and event publishing" +``` + +--- + +## Phase 3 — OS socket probing + +Each OS adapter is independently testable via mocked I/O. + +### Task 8: Define `SocketProbe` service contract + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/SocketProbe.ts` + +- [ ] **Step 1: Create the facade interface** + +Create `apps/server/src/detectedServers/Layers/SocketProbe.ts`: + +```ts +import { Effect, Context } from "effect"; + +export interface ProbeResult { + pid: number; + port: number; + host: string; +} + +export interface SocketProbeShape { + /** + * Probe for LISTEN sockets owned by any of the given pids. + * Returns rows of (pid, port, host) — empty when unavailable. + */ + readonly probe: (pids: ReadonlyArray) => Effect.Effect>; +} + +export class SocketProbe extends Context.Service()( + "s3/detectedServers/Layers/SocketProbe", +) {} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/SocketProbe.ts +git commit -m "Add SocketProbe service tag" +``` + +--- + +### Task 9: Implement `SocketProbe.Linux` + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts` +- Create: `apps/server/src/detectedServers/SocketProbe.Linux.test.ts` +- Create: `apps/server/src/detectedServers/__fixtures__/proc/tcp.txt` +- Create: `apps/server/src/detectedServers/__fixtures__/proc/tcp6.txt` + +- [ ] **Step 1: Create fixtures** + +`__fixtures__/proc/tcp.txt`: + +``` + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1451 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0 + 1: 00000000:14B5 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 67890 1 0000000000000000 100 0 0 10 0 + 2: 0100007F:1452 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 11111 1 0000000000000000 100 0 0 10 0 +``` + +(`0A` = LISTEN state. Port `1451` = 5201 decimal, `14B5` = 5301, `1452` = 5202.) + +`__fixtures__/proc/tcp6.txt`: + +``` + sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 22222 1 0000000000000000 100 0 0 10 0 +``` + +(`1F90` = 8080 decimal.) + +- [ ] **Step 2: Write the failing test** + +Create `apps/server/src/detectedServers/SocketProbe.Linux.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { readFileSync } from "node:fs"; +import { parseProcTcpRows, parseProcTcp6Rows } from "./Layers/SocketProbe.Linux.ts"; + +const fixture = (name: string) => + readFileSync(join(import.meta.dirname, "__fixtures__/proc", `${name}.txt`), "utf8"); + +describe("SocketProbe.Linux parsers", () => { + it("parses LISTEN sockets from /proc//net/tcp", () => { + const rows = parseProcTcpRows(fixture("tcp")); + const listening = rows.filter((r) => r.state === "LISTEN"); + expect(listening).toHaveLength(2); + expect(listening[0]!.port).toBe(5201); + expect(listening[0]!.host).toBe("127.0.0.1"); + expect(listening[1]!.port).toBe(5301); + expect(listening[1]!.host).toBe("0.0.0.0"); + }); + + it("excludes non-LISTEN rows", () => { + const rows = parseProcTcpRows(fixture("tcp")); + const established = rows.find((r) => r.state !== "LISTEN"); + expect(established?.port).toBe(5202); + }); + + it("parses LISTEN sockets from /proc//net/tcp6", () => { + const rows = parseProcTcp6Rows(fixture("tcp6")); + const listening = rows.filter((r) => r.state === "LISTEN"); + expect(listening).toHaveLength(1); + expect(listening[0]!.port).toBe(8080); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bun run test --filter @ryco/server -- SocketProbe.Linux` +Expected: FAIL, module not found. + +- [ ] **Step 4: Implement the parsers + layer** + +Create `apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts`: + +```ts +import { Effect, Layer } from "effect"; +import { readFile, readdir, readlink } from "node:fs/promises"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; + +export interface ProcTcpRow { + inode: number; + port: number; + host: string; + state: "LISTEN" | string; +} + +const STATE_MAP: Record = { "0A": "LISTEN" }; + +const hexToIpv4 = (hex: string): string => { + // /proc reverses byte order: "0100007F" → "127.0.0.1" + const bytes = [hex.slice(6, 8), hex.slice(4, 6), hex.slice(2, 4), hex.slice(0, 2)]; + return bytes.map((b) => Number.parseInt(b, 16)).join("."); +}; + +const hexToIpv6 = (hex: string): string => { + if (hex === "00000000000000000000000000000000") return "::"; + const groups: string[] = []; + for (let i = 0; i < 8; i += 1) { + const start = i * 4; + groups.push(hex.slice(start, start + 4).toLowerCase()); + } + return groups.join(":"); +}; + +export const parseProcTcpRows = (text: string): ProcTcpRow[] => { + const lines = text + .split("\n") + .slice(1) + .filter((l) => l.trim().length > 0); + return lines.map((line) => { + const parts = line.trim().split(/\s+/); + const [hostHex, portHex] = parts[1]!.split(":"); + const state = STATE_MAP[parts[3]!] ?? parts[3]!; + return { + inode: Number.parseInt(parts[9]!, 10), + port: Number.parseInt(portHex!, 16), + host: hexToIpv4(hostHex!), + state, + }; + }); +}; + +export const parseProcTcp6Rows = (text: string): ProcTcpRow[] => { + const lines = text + .split("\n") + .slice(1) + .filter((l) => l.trim().length > 0); + return lines.map((line) => { + const parts = line.trim().split(/\s+/); + const [hostHex, portHex] = parts[1]!.split(":"); + const state = STATE_MAP[parts[3]!] ?? parts[3]!; + return { + inode: Number.parseInt(parts[9]!, 10), + port: Number.parseInt(portHex!, 16), + host: hostHex === "00000000000000000000000000000000" ? "::" : hexToIpv6(hostHex!), + state, + }; + }); +}; + +const inodesForPid = (pid: number): Effect.Effect> => + Effect.tryPromise({ + try: async () => { + const fdDir = `/proc/${pid}/fd`; + const entries = await readdir(fdDir); + const inodes = new Set(); + await Promise.all( + entries.map(async (e) => { + try { + const target = await readlink(`${fdDir}/${e}`); + const m = target.match(/^socket:\[(\d+)\]$/); + if (m) inodes.add(Number.parseInt(m[1]!, 10)); + } catch { + // fd may have closed between readdir and readlink — ignore + } + }), + ); + return inodes; + }, + catch: () => new Error("inode lookup failed"), + }).pipe(Effect.orElseSucceed(() => new Set())); + +const probeImpl = (pids: ReadonlyArray): Effect.Effect> => + Effect.gen(function* () { + const pidInodes = yield* Effect.all( + pids.map((pid) => Effect.map(inodesForPid(pid), (inodes) => ({ pid, inodes }))), + ); + const inodeToPid = new Map(); + for (const { pid, inodes } of pidInodes) { + for (const inode of inodes) inodeToPid.set(inode, pid); + } + + const tcpText = yield* Effect.tryPromise({ + try: () => readFile("/proc/net/tcp", "utf8"), + catch: () => new Error("read /proc/net/tcp failed"), + }).pipe(Effect.orElseSucceed(() => "")); + const tcp6Text = yield* Effect.tryPromise({ + try: () => readFile("/proc/net/tcp6", "utf8"), + catch: () => new Error("read /proc/net/tcp6 failed"), + }).pipe(Effect.orElseSucceed(() => "")); + + const rows = [...parseProcTcpRows(tcpText), ...parseProcTcp6Rows(tcp6Text)]; + return rows + .filter((r) => r.state === "LISTEN" && inodeToPid.has(r.inode)) + .map((r) => ({ pid: inodeToPid.get(r.inode)!, port: r.port, host: r.host })); + }); + +export const SocketProbeLinuxLive = Layer.succeed(SocketProbe, { probe: probeImpl }); +``` + +- [ ] **Step 5: Run tests to verify pass** + +Run: `bun run test --filter @ryco/server -- SocketProbe.Linux` +Expected: all 3 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts apps/server/src/detectedServers/SocketProbe.Linux.test.ts apps/server/src/detectedServers/__fixtures__/proc/ +git commit -m "Add Linux SocketProbe via /proc//net/tcp parsing" +``` + +--- + +### Task 10: Implement `SocketProbe.Darwin` + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts` +- Create: `apps/server/src/detectedServers/SocketProbe.Darwin.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/server/src/detectedServers/SocketProbe.Darwin.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { parseLsofOutput } from "./Layers/SocketProbe.Darwin.ts"; + +describe("SocketProbe.Darwin.parseLsofOutput", () => { + it("parses lsof TCP LISTEN rows", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 alice 23u IPv4 0xabc12345abc1234 0t0 TCP 127.0.0.1:5173 (LISTEN) +node 12345 alice 24u IPv6 0xabc12345abc1235 0t0 TCP [::1]:5173 (LISTEN) +node 99999 alice 25u IPv4 0xabc12345abc1236 0t0 TCP *:3000 (LISTEN) +`; + const rows = parseLsofOutput(output); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual({ pid: 12345, port: 5173, host: "127.0.0.1" }); + expect(rows[1]).toEqual({ pid: 12345, port: 5173, host: "::1" }); + expect(rows[2]).toEqual({ pid: 99999, port: 3000, host: "0.0.0.0" }); + }); + + it("returns empty array for empty output", () => { + expect(parseLsofOutput("")).toEqual([]); + }); + + it("ignores rows not in LISTEN state", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 alice 23u IPv4 0xabc12345abc1234 0t0 TCP 127.0.0.1:5173->127.0.0.1:99 (ESTABLISHED) +`; + expect(parseLsofOutput(output)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test --filter @ryco/server -- SocketProbe.Darwin` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement parser + layer** + +Create `apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts`: + +```ts +import { Effect, Layer } from "effect"; +import { spawn } from "node:child_process"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; + +export const parseLsofOutput = (text: string): ProbeResult[] => { + if (!text.trim()) return []; + const lines = text.split("\n").slice(1); + const out: ProbeResult[] = []; + for (const line of lines) { + if (!line.includes("(LISTEN)")) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 9) continue; + const pid = Number.parseInt(parts[1]!, 10); + const nameField = parts.slice(8, parts.length - 1).join(" "); + let host = "0.0.0.0"; + let port = -1; + const ipv6 = nameField.match(/^\[([^\]]+)\]:(\d+)/); + const ipv4 = nameField.match(/^([^:]+):(\d+)/); + if (ipv6) { + host = ipv6[1]!; + port = Number.parseInt(ipv6[2]!, 10); + } else if (ipv4) { + host = ipv4[1] === "*" ? "0.0.0.0" : ipv4[1]!; + port = Number.parseInt(ipv4[2]!, 10); + } + if (port > 0) out.push({ pid, port, host }); + } + return out; +}; + +const runLsof = (pids: ReadonlyArray): Effect.Effect => + Effect.async((resume) => { + if (pids.length === 0) { + resume(Effect.succeed("")); + return; + } + const child = spawn("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], { + stdio: ["ignore", "pipe", "ignore"], + }); + let buf = ""; + child.stdout.on("data", (d: Buffer) => (buf += d.toString("utf8"))); + child.on("error", () => resume(Effect.succeed(""))); + child.on("close", () => resume(Effect.succeed(buf))); + }); + +const probeImpl = (pids: ReadonlyArray): Effect.Effect> => + Effect.gen(function* () { + const out = yield* runLsof(pids); + return parseLsofOutput(out); + }); + +export const SocketProbeDarwinLive = Layer.succeed(SocketProbe, { probe: probeImpl }); +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun run test --filter @ryco/server -- SocketProbe.Darwin` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts apps/server/src/detectedServers/SocketProbe.Darwin.test.ts +git commit -m "Add Darwin SocketProbe via lsof" +``` + +--- + +### Task 11: Implement `SocketProbe.Windows` + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts` +- Create: `apps/server/src/detectedServers/SocketProbe.Windows.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/server/src/detectedServers/SocketProbe.Windows.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { parseNetstatOutput } from "./Layers/SocketProbe.Windows.ts"; + +describe("SocketProbe.Windows.parseNetstatOutput", () => { + it("parses LISTENING rows", () => { + const output = ` +Active Connections + + Proto Local Address Foreign Address State PID + TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234 + TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 9876 + TCP 127.0.0.1:5173 127.0.0.1:54321 ESTABLISHED 9876 +`; + const rows = parseNetstatOutput(output); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ pid: 1234, port: 135, host: "0.0.0.0" }); + expect(rows[1]).toEqual({ pid: 9876, port: 5173, host: "127.0.0.1" }); + }); + + it("handles IPv6 brackets", () => { + const output = ` + TCP [::]:8080 [::]:0 LISTENING 4242 +`; + const rows = parseNetstatOutput(output); + expect(rows).toEqual([{ pid: 4242, port: 8080, host: "::" }]); + }); + + it("returns empty for no LISTENING rows", () => { + expect(parseNetstatOutput("")).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test --filter @ryco/server -- SocketProbe.Windows` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement parser + layer** + +Create `apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts`: + +```ts +import { Effect, Layer } from "effect"; +import { spawn } from "node:child_process"; +import { SocketProbe, type ProbeResult } from "./SocketProbe.ts"; + +export const parseNetstatOutput = (text: string): ProbeResult[] => { + if (!text.trim()) return []; + const out: ProbeResult[] = []; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line.startsWith("TCP")) continue; + if (!line.includes("LISTENING")) continue; + const parts = line.split(/\s+/); + if (parts.length < 5) continue; + const local = parts[1]!; + const pid = Number.parseInt(parts[4]!, 10); + let host: string; + let port: number; + if (local.startsWith("[")) { + const m = local.match(/^\[([^\]]+)\]:(\d+)$/); + if (!m) continue; + host = m[1]!; + port = Number.parseInt(m[2]!, 10); + } else { + const idx = local.lastIndexOf(":"); + if (idx < 0) continue; + host = local.slice(0, idx); + port = Number.parseInt(local.slice(idx + 1), 10); + } + if (Number.isFinite(pid) && Number.isFinite(port)) out.push({ pid, port, host }); + } + return out; +}; + +const runNetstat = (): Effect.Effect => + Effect.async((resume) => { + const child = spawn("netstat", ["-ano"], { stdio: ["ignore", "pipe", "ignore"] }); + let buf = ""; + child.stdout.on("data", (d: Buffer) => (buf += d.toString("utf8"))); + child.on("error", () => resume(Effect.succeed(""))); + child.on("close", () => resume(Effect.succeed(buf))); + }); + +const probeImpl = (pids: ReadonlyArray): Effect.Effect> => + Effect.gen(function* () { + if (pids.length === 0) return []; + const out = yield* runNetstat(); + const pidSet = new Set(pids); + return parseNetstatOutput(out).filter((r) => pidSet.has(r.pid)); + }); + +export const SocketProbeWindowsLive = Layer.succeed(SocketProbe, { probe: probeImpl }); +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun run test --filter @ryco/server -- SocketProbe.Windows` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts apps/server/src/detectedServers/SocketProbe.Windows.test.ts +git commit -m "Add Windows SocketProbe via netstat -ano" +``` + +--- + +### Task 12: Add OS-selecting `SocketProbe` layer + +**Files:** + +- Modify: `apps/server/src/detectedServers/Layers/SocketProbe.ts` + +- [ ] **Step 1: Add the runtime OS selector** + +At the bottom of `apps/server/src/detectedServers/Layers/SocketProbe.ts`, append: + +```ts +import { Effect, Layer } from "effect"; +import { platform } from "node:os"; +import { SocketProbeLinuxLive } from "./SocketProbe.Linux.ts"; +import { SocketProbeDarwinLive } from "./SocketProbe.Darwin.ts"; +import { SocketProbeWindowsLive } from "./SocketProbe.Windows.ts"; + +const NoopProbeLive = Layer.succeed(SocketProbe, { + probe: () => Effect.succeed([] as ReadonlyArray), +}); + +export const SocketProbeLive: Layer.Layer = (() => { + switch (platform()) { + case "linux": + return SocketProbeLinuxLive; + case "darwin": + return SocketProbeDarwinLive; + case "win32": + return SocketProbeWindowsLive; + default: + return NoopProbeLive; + } +})(); +``` + +(The top of the file already imports `Effect, Context`; restructure imports if needed so the file has only one `import { Effect, ... } from "effect"` block. The `Layer` import can be merged into the existing `effect` import.) + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/SocketProbe.ts +git commit -m "Add runtime OS-selecting SocketProbe layer" +``` + +--- + +## Phase 4 — Liveness + ingress composition + +### Task 13: Implement `LivenessHeartbeat` + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts` + +- [ ] **Step 1: Implement the service** + +Create `apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts`: + +```ts +import { Effect, Context, Layer } from "effect"; + +export interface LivenessHeartbeatShape { + /** + * Single liveness check. Returns true if any HTTP response was received + * (any 2xx/3xx/4xx/5xx counts as "the server is up"). + */ + readonly check: (url: string) => Effect.Effect; +} + +export class LivenessHeartbeat extends Context.Service()( + "s3/detectedServers/Layers/LivenessHeartbeat", +) {} + +const checkImpl = (url: string): Effect.Effect => + Effect.tryPromise({ + try: () => fetch(url, { method: "HEAD", signal: AbortSignal.timeout(500) }), + catch: () => new Error("heartbeat failed"), + }).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + +export const LivenessHeartbeatLive = Layer.succeed(LivenessHeartbeat, { check: checkImpl }); +``` + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts +git commit -m "Add LivenessHeartbeat service for HEAD-probe checks" +``` + +--- + +### Task 14: Define `DetectedServerRegistry` Service tag wrapping the in-memory `Registry` + +**Files:** + +- Create: `apps/server/src/detectedServers/Services/DetectedServerRegistry.ts` + +- [ ] **Step 1: Create the service tag and Layer** + +Create `apps/server/src/detectedServers/Services/DetectedServerRegistry.ts`: + +```ts +import { Effect, Context, Layer } from "effect"; +import type { DetectedServer, DetectedServerEvent } from "@ryco/contracts"; +import { Registry, type RegistryRegisterInput } from "../Layers/Registry.ts"; + +export interface DetectedServerRegistryShape { + readonly registerOrUpdate: (input: RegistryRegisterInput) => Effect.Effect; + readonly publishLog: (serverId: string, data: string) => Effect.Effect; + readonly remove: (serverId: string) => Effect.Effect; + readonly subscribe: ( + threadId: string, + listener: (e: DetectedServerEvent) => void, + ) => Effect.Effect<() => void>; + readonly getCurrent: (threadId: string) => Effect.Effect>; + readonly findById: (serverId: string) => Effect.Effect; +} + +export class DetectedServerRegistry extends Context.Service< + DetectedServerRegistry, + DetectedServerRegistryShape +>()("s3/detectedServers/Services/DetectedServerRegistry") {} + +export const DetectedServerRegistryLive = Layer.sync(DetectedServerRegistry, () => { + const r = new Registry(); + return { + registerOrUpdate: (input) => Effect.sync(() => r.registerOrUpdate(input)), + publishLog: (id, data) => Effect.sync(() => r.publishLog(id, data)), + remove: (id) => Effect.sync(() => r.remove(id)), + subscribe: (tid, listener) => Effect.sync(() => r.subscribe(tid, listener)), + getCurrent: (tid) => Effect.sync(() => r.getCurrent(tid)), + findById: (id) => Effect.sync(() => r.findById(id)), + }; +}); +``` + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/detectedServers/Services/DetectedServerRegistry.ts +git commit -m "Add DetectedServerRegistry Effect Service wrapping the in-memory Registry" +``` + +--- + +### Task 15: Build `DetectedServersIngress` layer composing everything + +**Files:** + +- Create: `apps/server/src/detectedServers/Layers/DetectedServersIngress.ts` +- Modify: `apps/server/src/serverLayers.ts` + +- [ ] **Step 1: Build the orchestrator** + +Create `apps/server/src/detectedServers/Layers/DetectedServersIngress.ts`: + +```ts +import { Effect, Layer, Fiber, Ref, Schedule, Duration } from "effect"; +import pidtree from "pidtree"; +import { DetectedServerRegistry } from "../Services/DetectedServerRegistry.ts"; +import { SocketProbe } from "./SocketProbe.ts"; +import { LivenessHeartbeat } from "./LivenessHeartbeat.ts"; +import { StdoutSniffer } from "./StdoutSniffer.ts"; +import { hintFromArgv, type PackageJsonShape } from "./ArgvHinter.ts"; + +const DEBUGGER_PORTS = new Set([9229, 9230]); + +const argvHasInspect = (argv: ReadonlyArray): number[] => { + const out: number[] = []; + for (const t of argv) { + const m = t.match(/--inspect(?:-brk|-wait)?=(?:[^:]*:)?(\d+)/); + if (m) out.push(Number.parseInt(m[1]!, 10)); + } + return out; +}; + +export interface CodexCommandSource { + threadId: string; + turnId: string; + itemId: string; + argv: ReadonlyArray; + cwd: string; +} + +export interface PtyCommandSource { + threadId: string; + pid: number; + argv: ReadonlyArray; + cwd: string; +} + +export interface DetectedServersIngressShape { + /** + * Begin tracking a Codex/ACP agent-internal command. + * Returns a feed() function for stdout chunks and an end() function for command completion. + */ + readonly trackAgentCommand: ( + source: CodexCommandSource, + sourceKind: "codex" | "acp", + pkg: PackageJsonShape | undefined, + ) => Effect.Effect<{ + feed: (chunk: string) => void; + end: (result: "success" | "error") => void; + }>; + + /** + * Begin tracking a local PTY spawn. + * Returns a feed() function for stdout chunks and an end() function for process exit. + */ + readonly trackPty: ( + source: PtyCommandSource, + pkg: PackageJsonShape | undefined, + ) => Effect.Effect<{ + feed: (chunk: string) => void; + end: (exitCode: number | null) => void; + }>; +} + +export class DetectedServersIngress extends Effect.Service()( + "s3/detectedServers/Layers/DetectedServersIngress", + { + effect: Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + const probe = yield* SocketProbe; + const heartbeat = yield* LivenessHeartbeat; + + const trackAgentCommand = ( + source: CodexCommandSource, + sourceKind: "codex" | "acp", + pkg: PackageJsonShape | undefined, + ) => + Effect.gen(function* () { + const hint = hintFromArgv(source.argv, pkg); + if (!hint.isLikelyServer) { + return { feed: () => {}, end: () => {} }; + } + const identityKey = `${source.threadId}::${sourceKind}::${source.turnId}::${source.itemId}`; + const server = yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: sourceKind, + identityKey, + patch: { + framework: hint.framework, + status: "predicted", + argv: source.argv, + cwd: source.cwd, + }, + }); + const sniffer = new StdoutSniffer(); + sniffer.onCandidate((c) => { + Effect.runPromise( + registry.registerOrUpdate({ + threadId: source.threadId, + source: sourceKind, + identityKey, + patch: { + status: "candidate", + framework: c.framework !== "unknown" ? c.framework : hint.framework, + url: c.url, + port: c.port, + host: c.host, + }, + }), + ).catch(() => {}); + }); + + return { + feed: (chunk: string) => { + sniffer.feed(chunk); + Effect.runPromise(registry.publishLog(server.id, chunk)).catch(() => {}); + }, + end: (result: "success" | "error") => { + Effect.runPromise( + registry.registerOrUpdate({ + threadId: source.threadId, + source: sourceKind, + identityKey, + patch: { + status: "exited", + exitedAt: new Date(), + exitReason: result === "success" ? "stopped" : "crashed", + }, + }), + ).catch(() => {}); + }, + }; + }); + + const trackPty = (source: PtyCommandSource, pkg: PackageJsonShape | undefined) => + Effect.gen(function* () { + const hint = hintFromArgv(source.argv, pkg); + if (!hint.isLikelyServer) return { feed: () => {}, end: () => {} }; + const identityKey = `${source.threadId}::pty::${source.pid}`; + const server = yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + framework: hint.framework, + status: "predicted", + pid: source.pid, + argv: source.argv, + cwd: source.cwd, + }, + }); + + const sniffer = new StdoutSniffer(); + let sniffedPort: number | null = null; + sniffer.onCandidate((c) => { + sniffedPort = c.port; + Effect.runPromise( + registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: "candidate", + framework: c.framework !== "unknown" ? c.framework : hint.framework, + url: c.url, + port: c.port, + host: c.host, + }, + }), + ).catch(() => {}); + }); + + const denyPorts = new Set([...DEBUGGER_PORTS, ...argvHasInspect(source.argv)]); + const probeKey = `${source.threadId}::${server.id}`; + + // Start probe fiber + const probeFiber = yield* Effect.fork( + Effect.gen(function* () { + let liveSeenAt: Date | null = null; + while (true) { + const pids = yield* Effect.tryPromise({ + try: () => pidtree(source.pid, { root: true }), + catch: () => new Error("pidtree failed"), + }).pipe(Effect.orElseSucceed(() => [source.pid])); + const rows = yield* probe.probe(pids); + const candidates = rows.filter((r) => !denyPorts.has(r.port)); + const matching = sniffedPort + ? candidates.find((r) => r.port === sniffedPort) + : candidates[0]; + if (matching && !liveSeenAt) { + const ok = yield* heartbeat.check(`http://localhost:${matching.port}`); + if (ok) { + liveSeenAt = new Date(); + yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: "live", + port: matching.port, + host: matching.host, + url: `http://localhost:${matching.port}`, + liveAt: liveSeenAt, + }, + }); + } else { + yield* registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: "confirmed", + port: matching.port, + host: matching.host, + }, + }); + } + } + yield* Effect.sleep(liveSeenAt ? Duration.seconds(2) : Duration.millis(250)); + } + }), + ); + + return { + feed: (chunk: string) => { + sniffer.feed(chunk); + Effect.runPromise(registry.publishLog(server.id, chunk)).catch(() => {}); + }, + end: (exitCode: number | null) => { + Effect.runPromise(Fiber.interrupt(probeFiber)).catch(() => {}); + Effect.runPromise( + registry.registerOrUpdate({ + threadId: source.threadId, + source: "pty", + identityKey, + patch: { + status: exitCode === 0 || exitCode === null ? "exited" : "crashed", + exitedAt: new Date(), + exitReason: exitCode === 0 || exitCode === null ? "stopped" : "crashed", + }, + }), + ).catch(() => {}); + }, + }; + }); + + return { trackAgentCommand, trackPty }; + }), + dependencies: [], + }, +) {} +``` + +(If `Effect.Service` static-builder syntax doesn't match this codebase's pattern, port to the explicit `Context.Service` + `Layer.effect` two-step form used in `terminal/Services/PTY.ts`. The shape stays identical.) + +- [ ] **Step 2: Wire the ingress + dependencies into `serverLayers.ts`** + +Modify `apps/server/src/serverLayers.ts` — locate the existing layer composition for terminal services. Add: + +```ts +import { SocketProbeLive } from "./detectedServers/Layers/SocketProbe.ts"; +import { LivenessHeartbeatLive } from "./detectedServers/Layers/LivenessHeartbeat.ts"; +import { DetectedServerRegistryLive } from "./detectedServers/Services/DetectedServerRegistry.ts"; +import { DetectedServersIngress } from "./detectedServers/Layers/DetectedServersIngress.ts"; + +// ... within the layer build: +const detectedServersLayer = Layer.mergeAll( + SocketProbeLive, + LivenessHeartbeatLive, + DetectedServerRegistryLive, + DetectedServersIngress.Default, +); +``` + +Merge `detectedServersLayer` into the main `serverLayers` composition next to the terminal layer block. + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 4: Run all server tests** + +Run: `bun run test --filter @ryco/server` +Expected: pre-existing tests still PASS; new tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/detectedServers/Layers/DetectedServersIngress.ts apps/server/src/serverLayers.ts +git commit -m "Compose DetectedServersIngress with probe, heartbeat, and registry" +``` + +--- + +## Phase 5 — Provider taps + +### Task 16: Tap Codex provider + +**Files:** + +- Modify: `apps/server/src/provider/Layers/CodexSessionRuntime.ts` + +- [ ] **Step 1: Inspect the existing notification routing** + +Open `apps/server/src/provider/Layers/CodexSessionRuntime.ts` and locate: + +- Handler for `item/commandExecution/requestApproval` (~lines 917-971 per the spec). +- Handler for `item/commandExecution/outputDelta` (~lines 549-550 per the spec). + +- [ ] **Step 2: Inject `DetectedServersIngress` into the runtime** + +At the top of `CodexSessionRuntime.ts`, add: + +```ts +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; +``` + +Add it as a dependency of the runtime construction (alongside other services already pulled with `yield* …`). Maintain a `Map` keyed by `${turnId}::${itemId}` inside the runtime state. + +- [ ] **Step 3: Hook `requestApproval`** + +In the existing `requestApproval` handler, after the user approval payload is parsed (where `argv` and `cwd` are available), call: + +```ts +const tracker = + yield * + ingress.trackAgentCommand( + { threadId, turnId, itemId, argv, cwd }, + "codex", + /* pkg */ undefined, // package.json read can be added later; undefined is safe + ); +trackerMap.set(`${turnId}::${itemId}`, tracker); +``` + +- [ ] **Step 4: Hook `outputDelta`** + +In the `outputDelta` handler, after locating the matching `(turnId, itemId)`: + +```ts +const tracker = trackerMap.get(`${turnId}::${itemId}`); +tracker?.feed(payload.delta); +``` + +(Adapt `payload.delta` to whatever the actual field name is — likely `payload.text` or `payload.output`. Grep the file for the existing accessor.) + +- [ ] **Step 5: Hook command completion** + +Find the existing handler for command-execution completion (search for `commandExecution/end` or `commandExecution/result` or whatever the actual notification method is). On completion: + +```ts +const tracker = trackerMap.get(`${turnId}::${itemId}`); +tracker?.end(payload.exitedSuccessfully ? "success" : "error"); +trackerMap.delete(`${turnId}::${itemId}`); +``` + +- [ ] **Step 6: Run typecheck and existing tests** + +Run: `bun run typecheck && bun run test --filter @ryco/server -- CodexSessionRuntime` +Expected: no errors; existing CodexSessionRuntime tests still PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/provider/Layers/CodexSessionRuntime.ts +git commit -m "Tap Codex command execution events for detected-server tracking" +``` + +--- + +### Task 17: Tap ACP provider (Claude/Cursor) + +**Files:** + +- Modify: `apps/server/src/provider/acp/AcpSessionRuntime.ts` + +- [ ] **Step 1: Inspect the existing ACP command-execution event shape** + +Open `apps/server/src/provider/acp/AcpSessionRuntime.ts` and locate the command-execution notification handler. Likely uses the same conceptual events as Codex but with ACP-specific naming. Grep the file for `commandExecution`, `tool`, `bash`, and similar tokens to find the right hook. + +- [ ] **Step 2: Mirror the Codex tap** + +Add `DetectedServersIngress` as a dependency, maintain a per-(turnId, itemId) tracker map, hook approval/output/completion analogously. Use `"acp"` as the `sourceKind`. + +```ts +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; + +// at the right call site: +const tracker = + yield * ingress.trackAgentCommand({ threadId, turnId, itemId, argv, cwd }, "acp", undefined); +trackerMap.set(`${turnId}::${itemId}`, tracker); + +// on output: +trackerMap.get(`${turnId}::${itemId}`)?.feed(text); + +// on completion: +trackerMap.get(`${turnId}::${itemId}`)?.end(success ? "success" : "error"); +trackerMap.delete(`${turnId}::${itemId}`); +``` + +- [ ] **Step 3: Run typecheck and tests** + +Run: `bun run typecheck && bun run test --filter @ryco/server -- AcpSessionRuntime` +Expected: no errors; existing tests still PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/provider/acp/AcpSessionRuntime.ts +git commit -m "Tap ACP command execution events for detected-server tracking" +``` + +--- + +### Task 18: Tap local PTY (terminal + OpenCode) + +**Files:** + +- Modify: `apps/server/src/terminal/Layers/Manager.ts` + +- [ ] **Step 1: Locate the PTY spawn site** + +Open `apps/server/src/terminal/Layers/Manager.ts`. Find the spot where `PtyAdapter.spawn` is awaited and the returned `PtyProcess` is attached. Per the spec, around lines 1389-1405. + +- [ ] **Step 2: Inject the ingress and instantiate a tracker per spawn** + +Import: + +```ts +import { DetectedServersIngress } from "../../detectedServers/Layers/DetectedServersIngress.ts"; +``` + +Pull it as a service dependency in the manager's effect: + +```ts +const ingress = yield * DetectedServersIngress; +``` + +After the `PtyProcess` is created and you know `pid`, `argv`, `cwd`: + +```ts +const tracker = + yield * + ingress.trackPty( + { threadId: session.threadId, pid: pty.pid, argv: shellArgs, cwd: session.cwd }, + undefined, + ); +``` + +Where `shellArgs` is the array `[session.shell, ...session.args]` or whatever the actual argv is at the spawn site. + +- [ ] **Step 3: Hook PTY data into the tracker** + +In `drainProcessEvents` (around `Manager.ts:1158-1267` per the spec), where `output` events are processed, add: + +```ts +tracker.feed(event.data); +``` + +— alongside the existing publishEvent / history append. + +- [ ] **Step 4: Hook PTY exit** + +In the exit handling path, add: + +```ts +tracker.end(event.exitCode ?? null); +``` + +- [ ] **Step 5: Run typecheck and tests** + +Run: `bun run typecheck && bun run test --filter @ryco/server -- terminal` +Expected: no errors; existing terminal tests still PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/terminal/Layers/Manager.ts +git commit -m "Tap local PTY events for detected-server tracking" +``` + +--- + +## Phase 6 — WS surface + +### Task 19: Wire `detectedServers.event` push channel + RPC handlers + +**Files:** + +- Modify: `apps/server/src/wsServer.ts` + +- [ ] **Step 1: Inspect existing RPC + push wiring** + +Search for `subscribeTerminalEvents` in `apps/server/src/wsServer.ts`. Note: + +- How the streaming RPC handler is registered. +- How the push channel envelope is constructed and routed through `ServerPushBus`. + +- [ ] **Step 2: Add the streaming subscribe handler** + +In `wsServer.ts` near `subscribeTerminalEvents`, register `subscribeDetectedServerEvents`: + +```ts +import { DetectedServerRegistry } from "./detectedServers/Services/DetectedServerRegistry.ts"; +import { open as openInBrowser } from "./open.ts"; + +// ...inside the handler registration block: +registerHandler("subscribeDetectedServerEvents", ({ threadId }) => + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + return Stream.async((emit) => { + // Replay current state first + Effect.runPromise(registry.getCurrent(threadId)).then((current) => { + for (const server of current) { + emit.single({ + type: "registered", + threadId, + server, + createdAt: new Date().toISOString(), + }); + } + }); + // Then stream live events + const unsubscribePromise = Effect.runPromise( + registry.subscribe(threadId, (e) => emit.single(e)), + ); + return Effect.promise(() => unsubscribePromise.then((fn) => Effect.sync(fn))); + }); + }), +); +``` + +(Adapt the streaming-handler signature to the exact pattern used by `subscribeTerminalEvents` — the codebase may use `Effect.Stream`, `Effect.async`, or a custom shape.) + +- [ ] **Step 3: Add `detectedServers.stop` handler** + +```ts +registerHandler("detectedServers.stop", ({ serverId }) => + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + const terminalMgr = yield* TerminalManager; + // Look up server by id — scan all threads. + // (For v1, registry exposes getCurrent per thread; iterate threads via a new + // method `findById` if not present. Add `findById` to Registry + service if needed.) + const server = yield* registry.findById(serverId); + if (!server) return { kind: "not-stoppable", hint: "interrupt-turn" } as const; + if (server.source === "pty" && server.pid !== undefined) { + // Find owning terminal session and call stop. The terminal manager + // already has SIGTERM→SIGKILL escalation. + yield* terminalMgr.stopByPid(server.pid); + return { kind: "stopped" } as const; + } + return { kind: "not-stoppable", hint: "interrupt-turn" } as const; + }), +); +``` + +If `Registry.findById` and `TerminalManager.stopByPid` don't exist, add them: `findById` is a flat scan of all threads' maps (small N); `stopByPid` walks the manager's session map and invokes the existing kill path on the matching session. + +- [ ] **Step 4: Add `detectedServers.openInBrowser` handler** + +```ts +registerHandler("detectedServers.openInBrowser", ({ serverId }) => + Effect.gen(function* () { + const registry = yield* DetectedServerRegistry; + const server = yield* registry.findById(serverId); + if (!server?.url) return { ok: false }; + yield* Effect.tryPromise(() => openInBrowser(server.url!)); + return { ok: true }; + }), +); +``` + +- [ ] **Step 5: Run typecheck and existing server tests** + +Run: `bun run typecheck && bun run test --filter @ryco/server` +Expected: no errors; pre-existing tests still PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/wsServer.ts apps/server/src/detectedServers/Services/DetectedServerRegistry.ts apps/server/src/detectedServers/Layers/Registry.ts +git commit -m "Wire detectedServers RPC handlers (subscribe, stop, openInBrowser)" +``` + +--- + +## Phase 7 — Backend integration tests + +### Task 20: Codex synthetic integration test + +**Files:** + +- Create: `apps/server/integration/detectedServersCodex.integration.test.ts` + +- [ ] **Step 1: Write the integration test** + +Create the test: + +```ts +import { describe, it, expect } from "vitest"; +import { Effect, Layer } from "effect"; +import { TestProviderAdapter } from "./TestProviderAdapter.integration.ts"; +import { DetectedServerRegistryLive } from "../src/detectedServers/Services/DetectedServerRegistry.ts"; +import { DetectedServerRegistry } from "../src/detectedServers/Services/DetectedServerRegistry.ts"; +import { SocketProbeLive } from "../src/detectedServers/Layers/SocketProbe.ts"; +import { LivenessHeartbeatLive } from "../src/detectedServers/Layers/LivenessHeartbeat.ts"; +import { DetectedServersIngress } from "../src/detectedServers/Layers/DetectedServersIngress.ts"; + +describe("DetectedServers / Codex synthetic", () => { + it("transitions predicted → candidate on Vite-shaped outputDelta", async () => { + const testLayer = Layer.mergeAll( + DetectedServerRegistryLive, + SocketProbeLive, + LivenessHeartbeatLive, + DetectedServersIngress.Default, + ); + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackAgentCommand( + { + threadId: "thread-1", + turnId: "turn-1", + itemId: "item-1", + argv: ["vite"], + cwd: "/tmp", + }, + "codex", + undefined, + ); + tracker.feed(" ➜ Local: http://localhost:5173/\n"); + // Allow the async sniffer callback to flush + await new Promise((r) => setTimeout(r, 50)); + const current = yield* registry.getCurrent("thread-1"); + expect(current).toHaveLength(1); + expect(current[0]!.status).toBe("candidate"); + expect(current[0]!.url).toBe("http://localhost:5173/"); + expect(current[0]!.framework).toBe("vite"); + }).pipe(Effect.provide(testLayer)), + ); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `bun run test --filter @ryco/server -- detectedServersCodex` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/integration/detectedServersCodex.integration.test.ts +git commit -m "Add Codex synthetic integration test for detected servers" +``` + +--- + +### Task 21: PTY real-server integration test + +**Files:** + +- Create: `apps/server/integration/detectedServersPty.integration.test.ts` + +- [ ] **Step 1: Write the integration test** + +```ts +import { describe, it, expect } from "vitest"; +import { Effect, Layer } from "effect"; +import { spawn } from "node:child_process"; +import { + DetectedServerRegistryLive, + DetectedServerRegistry, +} from "../src/detectedServers/Services/DetectedServerRegistry.ts"; +import { SocketProbeLive } from "../src/detectedServers/Layers/SocketProbe.ts"; +import { LivenessHeartbeatLive } from "../src/detectedServers/Layers/LivenessHeartbeat.ts"; +import { DetectedServersIngress } from "../src/detectedServers/Layers/DetectedServersIngress.ts"; + +describe("DetectedServers / PTY real server", () => { + it("transitions predicted → candidate → confirmed → live for a real Node http server", async () => { + const child = spawn( + process.execPath, + [ + "-e", + `const http = require("node:http"); const s = http.createServer((req,res)=>res.end("ok")); s.listen(0, "127.0.0.1", () => { console.log("Server listening on port " + s.address().port); });`, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + try { + const testLayer = Layer.mergeAll( + DetectedServerRegistryLive, + SocketProbeLive, + LivenessHeartbeatLive, + DetectedServersIngress.Default, + ); + + await Effect.runPromise( + Effect.gen(function* () { + const ingress = yield* DetectedServersIngress; + const registry = yield* DetectedServerRegistry; + const tracker = yield* ingress.trackPty( + { threadId: "thread-1", pid: child.pid!, argv: ["node", "-e", "..."], cwd: "/tmp" }, + { scripts: { dev: "node -e foo" } }, // mark as a dev-ish command + ); + child.stdout.on("data", (d) => tracker.feed(d.toString("utf8"))); + + // Poll for live with timeout + let server = null; + for (let i = 0; i < 100; i += 1) { + await new Promise((r) => setTimeout(r, 100)); + const current = yield* registry.getCurrent("thread-1"); + if (current[0]?.status === "live") { + server = current[0]; + break; + } + } + expect(server).not.toBeNull(); + expect(server!.status).toBe("live"); + expect(server!.port).toBeGreaterThan(0); + }).pipe(Effect.provide(testLayer)), + ); + } finally { + child.kill(); + } + }, 15_000); +}); +``` + +Note: this test currently relies on the ArgvHinter recognising the command. Since `node -e ...` isn't a known framework token, the test stubs in a fake `pkg` with a `scripts.dev` entry to coerce the hint. Alternatively, expand ArgvHinter to treat `node -e` invocations as `unknown, isLikelyServer: true` when run under a PTY whose parent is an agent — but for v1, the explicit stub is fine. + +- [ ] **Step 2: Run the test on Linux/macOS** + +Run: `bun run test --filter @ryco/server -- detectedServersPty` +Expected: PASS within ~5 seconds on Linux/macOS. Skip on Windows (mark `it.skipIf(process.platform === 'win32')`) if `netstat -ano` parsing isn't viable in CI. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/integration/detectedServersPty.integration.test.ts +git commit -m "Add PTY real-server integration test for detected servers" +``` + +--- + +## Phase 8 — Web foundations + +### Task 22: Add `detectedServerStore` Zustand slice + +**Files:** + +- Create: `apps/web/src/detectedServerStore.ts` +- Create: `apps/web/src/detectedServerStore.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/detectedServerStore.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import { useDetectedServerStore } from "./detectedServerStore.ts"; + +describe("detectedServerStore", () => { + beforeEach(() => useDetectedServerStore.getState().__reset()); + + it("registered adds a server to the thread map", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + }, + }); + expect(useDetectedServerStore.getState().serversByThreadKey["t1"]?.size).toBe(1); + }); + + it("updated mutates an existing server", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + }, + }); + store.handleEvent("t1", { + type: "updated", + threadId: "t1", + createdAt: "2026-05-13T00:00:01Z", + serverId: "s1", + patch: { status: "live", url: "http://localhost:5173/" }, + }); + const server = useDetectedServerStore.getState().serversByThreadKey["t1"]!.get("s1"); + expect(server?.status).toBe("live"); + expect(server?.url).toBe("http://localhost:5173/"); + }); + + it("log appends to the per-server buffer with a cap", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + }, + }); + store.handleEvent("t1", { + type: "log", + threadId: "t1", + createdAt: "2026-05-13T00:00:01Z", + serverId: "s1", + data: "hello\nworld\n", + }); + const buf = useDetectedServerStore.getState().logBuffersByServerId.get("s1"); + expect(buf?.snapshot()).toEqual(["hello", "world"]); + }); + + it("removed drops the server and its log buffer", () => { + const store = useDetectedServerStore.getState(); + store.handleEvent("t1", { + type: "registered", + threadId: "t1", + createdAt: "2026-05-13T00:00:00Z", + server: { + id: "s1", + threadId: "t1", + source: "pty", + framework: "vite", + status: "predicted", + startedAt: new Date(), + lastSeenAt: new Date(), + }, + }); + store.handleEvent("t1", { + type: "removed", + threadId: "t1", + createdAt: "2026-05-13T00:00:02Z", + serverId: "s1", + }); + expect(useDetectedServerStore.getState().serversByThreadKey["t1"]?.size ?? 0).toBe(0); + expect(useDetectedServerStore.getState().logBuffersByServerId.has("s1")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test --filter @ryco/web -- detectedServerStore` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement the store** + +Create `apps/web/src/detectedServerStore.ts`: + +```ts +import { create } from "zustand"; +import type { DetectedServer, DetectedServerEvent } from "@ryco/contracts"; +import { LineBuffer } from "@ryco/shared/lineBuffer"; + +const MAX_LOG_LINES = 5000; + +interface State { + serversByThreadKey: Record>; + logBuffersByServerId: Map; + activeServerIdByThreadKey: Record; + handleEvent: (threadKey: string, event: DetectedServerEvent) => void; + setActive: (threadKey: string, serverId: string | null) => void; + __reset: () => void; +} + +export const useDetectedServerStore = create((set, get) => ({ + serversByThreadKey: {}, + logBuffersByServerId: new Map(), + activeServerIdByThreadKey: {}, + + handleEvent: (threadKey, event) => { + const next = { ...get().serversByThreadKey }; + const map = new Map(next[threadKey] ?? []); + const logs = new Map(get().logBuffersByServerId); + + if (event.type === "registered") { + map.set(event.server.id, event.server); + if (!logs.has(event.server.id)) { + logs.set(event.server.id, new LineBuffer({ maxLines: MAX_LOG_LINES })); + } + } else if (event.type === "updated") { + const existing = map.get(event.serverId); + if (existing) map.set(event.serverId, { ...existing, ...event.patch }); + } else if (event.type === "log") { + const buf = logs.get(event.serverId); + buf?.write(event.data); + } else if (event.type === "removed") { + map.delete(event.serverId); + logs.delete(event.serverId); + } + + next[threadKey] = map; + set({ serversByThreadKey: next, logBuffersByServerId: logs }); + }, + + setActive: (threadKey, serverId) => + set({ + activeServerIdByThreadKey: { ...get().activeServerIdByThreadKey, [threadKey]: serverId }, + }), + + __reset: () => + set({ + serversByThreadKey: {}, + logBuffersByServerId: new Map(), + activeServerIdByThreadKey: {}, + }), +})); +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun run test --filter @ryco/web -- detectedServerStore` +Expected: all 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/detectedServerStore.ts apps/web/src/detectedServerStore.test.ts +git commit -m "Add detectedServerStore Zustand slice" +``` + +--- + +### Task 23: Subscribe to detected-server events in the WS RPC client + +**Files:** + +- Modify: `apps/web/src/rpc/wsRpcClient.ts` + +- [ ] **Step 1: Inspect existing terminal subscription pattern** + +Search `apps/web/src/rpc/wsRpcClient.ts` for `terminal.onEvent` or `subscribeTerminalEvents`. Note its `(listener) => unsubscribe()` shape. + +- [ ] **Step 2: Add the parallel subscriber** + +Mirror the terminal subscribe pattern, e.g.: + +```ts +detectedServers: { + onEvent( + threadId: string, + listener: (event: DetectedServerEvent) => void, + ): () => void { + return subscribeStream("subscribeDetectedServerEvents", { threadId }, listener); + }, + stop(serverId: string): Promise { + return callRpc("detectedServers.stop", { serverId }); + }, + openInBrowser(serverId: string): Promise<{ ok: boolean }> { + return callRpc("detectedServers.openInBrowser", { serverId }); + }, +}, +``` + +(Adapt names — `subscribeStream` / `callRpc` are placeholders for the real helpers in this file.) + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/rpc/wsRpcClient.ts +git commit -m "Add detectedServers subscription to WS RPC client" +``` + +--- + +## Phase 9 — Web UI + +### Task 24: Build `DetectedServersBadge` + +**Files:** + +- Create: `apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx` +- Create: `apps/web/src/components/BranchToolbar/DetectedServersBadge.test.tsx` +- Modify: `apps/web/src/components/BranchToolbar.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `DetectedServersBadge.test.tsx`: + +```tsx +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DetectedServersBadge } from "./DetectedServersBadge.tsx"; +import type { DetectedServer } from "@ryco/contracts"; + +const make = (overrides: Partial): DetectedServer => ({ + id: "s", + threadId: "t", + source: "pty", + framework: "vite", + status: "live", + startedAt: new Date(), + lastSeenAt: new Date(), + ...overrides, +}); + +describe("DetectedServersBadge", () => { + it("renders nothing when no servers", () => { + const { container } = render( {}} />); + expect(container.firstChild).toBeNull(); + }); + + it("renders count when 1+ servers", () => { + render( {}} />); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("applies pulsing class when any server is predicted or candidate", () => { + const { container } = render( + {}} />, + ); + expect(container.querySelector('[data-state="pulsing"]')).not.toBeNull(); + }); + + it("no pulsing class when all servers are live", () => { + const { container } = render( + {}} />, + ); + expect(container.querySelector('[data-state="pulsing"]')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Implement the component** + +Create `DetectedServersBadge.tsx`: + +```tsx +import { Server } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.tsx"; +import type { DetectedServer } from "@ryco/contracts"; + +interface Props { + servers: DetectedServer[]; + onClick: () => void; +} + +export const DetectedServersBadge = ({ servers, onClick }: Props) => { + if (servers.length === 0) return null; + const isPulsing = servers.some((s) => s.status === "predicted" || s.status === "candidate"); + return ( + + + + + +
    + {servers.map((s) => ( +
  • + {s.framework} + {s.url ? · {s.url} : null} + · {s.status} +
  • + ))} +
+
+
+ ); +}; +``` + +- [ ] **Step 3: Slot the badge into `BranchToolbar`** + +In `apps/web/src/components/BranchToolbar.tsx`: + +```tsx +import { DetectedServersBadge } from "./BranchToolbar/DetectedServersBadge.tsx"; +import { useDetectedServerStore } from "../detectedServerStore.ts"; + +// inside the component: +const servers = useDetectedServerStore((s) => { + const m = s.serversByThreadKey[threadKey]; + return m ? [...m.values()] : []; +}); + +// in JSX, alongside the terminal toggle button: +; +``` + +`openServersTab` is a callback the parent (`ChatView`) passes down that sets the drawer to open with the Servers kind active. + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun run test --filter @ryco/web -- DetectedServersBadge` +Expected: all 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/BranchToolbar.tsx apps/web/src/components/BranchToolbar/ +git commit -m "Add DetectedServersBadge to BranchToolbar" +``` + +--- + +### Task 25: Add kind tabset to `ThreadTerminalDrawer` + +**Files:** + +- Modify: `apps/web/src/components/ThreadTerminalDrawer.tsx` +- Modify: `apps/web/src/terminalStateStore.ts` + +- [ ] **Step 1: Add `terminalDrawerKind` to the terminal state store** + +In `apps/web/src/terminalStateStore.ts`, add per-thread field: + +```ts +type DrawerKind = "terminals" | "servers"; + +// In the per-thread state shape: +terminalDrawerKind: DrawerKind; // default "terminals" + +// In the action set: +setTerminalDrawerKind: (threadKey: string, kind: DrawerKind) => void; +``` + +Implement `setTerminalDrawerKind` to update the per-thread record and persist alongside drawer height. Include `terminalDrawerKind` in the localStorage serialization. + +- [ ] **Step 2: Add kind tabs to the drawer** + +In `ThreadTerminalDrawer.tsx`, above the current terminal-tab strip, render a small two-button toggle: + +```tsx +
+ + +
+``` + +- [ ] **Step 3: Render the right content based on `drawerKind`** + +```tsx +{drawerKind === "terminals" ? ( + +) : ( + +)} +``` + +`DetectedServersPanel` will be implemented in the next task. For now, stub it as `() =>
Servers
` so the file compiles. + +- [ ] **Step 4: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/ThreadTerminalDrawer.tsx apps/web/src/terminalStateStore.ts +git commit -m "Add kind tabset (Terminals / Servers) to ThreadTerminalDrawer" +``` + +--- + +### Task 26: Build `DetectedServersPanel` + `DetectedServerRow` + +**Files:** + +- Create: `apps/web/src/components/detectedServers/DetectedServersPanel.tsx` +- Create: `apps/web/src/components/detectedServers/DetectedServerRow.tsx` + +- [ ] **Step 1: Implement `DetectedServerRow`** + +```tsx +import { Server, ExternalLink, Square, Copy } from "lucide-react"; +import type { DetectedServer } from "@ryco/contracts"; +import { Button } from "../ui/button.tsx"; +import { cn } from "../../lib/utils.ts"; + +const STATUS_PILL_CLASS: Record = { + predicted: "bg-blue-500/20 text-blue-300", + candidate: "bg-yellow-500/20 text-yellow-300 animate-pulse", + confirmed: "bg-cyan-500/20 text-cyan-300", + live: "bg-green-500/20 text-green-300", + restarting: "bg-orange-500/20 text-orange-300 animate-pulse", + exited: "bg-muted text-muted-foreground", + crashed: "bg-red-500/20 text-red-300", +}; + +interface Props { + server: DetectedServer; + active: boolean; + onSelect: () => void; + onOpen: () => void; + onCopy: () => void; + onStop: () => void; +} + +export const DetectedServerRow = ({ server, active, onSelect, onOpen, onCopy, onStop }: Props) => ( + + )} + {server.url && ( + + )} + +
+ +); +``` + +- [ ] **Step 2: Implement `DetectedServersPanel`** + +```tsx +import { useMemo } from "react"; +import { useDetectedServerStore } from "../../detectedServerStore.ts"; +import { DetectedServerRow } from "./DetectedServerRow.tsx"; +import { DetectedServerLogView } from "./DetectedServerLogView.tsx"; +import { rpcClient } from "../../rpc/wsRpcClient.ts"; +import { toastManager } from "../../toastManager.ts"; + +interface Props { + threadKey: string; +} + +export const DetectedServersPanel = ({ threadKey }: Props) => { + const serversMap = useDetectedServerStore((s) => s.serversByThreadKey[threadKey]); + const activeId = useDetectedServerStore((s) => s.activeServerIdByThreadKey[threadKey] ?? null); + const setActive = useDetectedServerStore((s) => s.setActive); + + const servers = useMemo(() => (serversMap ? [...serversMap.values()] : []), [serversMap]); + const active = activeId && serversMap ? (serversMap.get(activeId) ?? null) : null; + + if (servers.length === 0) { + return ( +
+ No servers detected yet. They'll appear here when an agent runs dev/ + serve commands. +
+ ); + } + + const handleStop = async (serverId: string) => { + const result = await rpcClient.detectedServers.stop(serverId); + if (result.kind === "not-stoppable") { + toastManager.show({ + title: "Server managed by agent", + description: "Interrupt the current turn to stop it.", + }); + } + }; + + const handleCopy = (url: string) => { + void navigator.clipboard.writeText(url); + toastManager.show({ title: "Copied", description: url }); + }; + + return ( +
+
+ {servers.map((s) => ( + setActive(threadKey, s.id)} + onOpen={() => void rpcClient.detectedServers.openInBrowser(s.id)} + onCopy={() => s.url && handleCopy(s.url)} + onStop={() => void handleStop(s.id)} + /> + ))} +
+
+ {active ? ( + + ) : ( +
+ Select a server to view logs +
+ )} +
+
+ ); +}; +``` + +Replace the `DetectedServersPanel` stub created in Task 25 with this real export. Note: `DetectedServerLogView` is built in the next task; for now temporarily stub it as `() =>
logs
` inline at the import site if needed to keep the build green. + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. (Stub `DetectedServerLogView` import temporarily if necessary.) + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/detectedServers/ +git commit -m "Add DetectedServersPanel and DetectedServerRow" +``` + +--- + +### Task 27: Build `DetectedServerLogView` with xterm.js + +**Files:** + +- Create: `apps/web/src/components/detectedServers/DetectedServerLogView.tsx` + +- [ ] **Step 1: Inspect the existing xterm.js mount pattern** + +Open `apps/web/src/components/ThreadTerminalDrawer.tsx`. Find where `new Terminal()` is constructed and `.open(element)` is called. Note: how `FitAddon` is attached, how `dispose()` is called on unmount, and how new data is `.write(data)`-ed to the terminal. + +- [ ] **Step 2: Implement the log view** + +```tsx +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import { useDetectedServerStore } from "../../detectedServerStore.ts"; + +interface Props { + serverId: string; +} + +export const DetectedServerLogView = ({ serverId }: Props) => { + const containerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const writtenLengthRef = useRef(0); + + const buffer = useDetectedServerStore((s) => s.logBuffersByServerId.get(serverId)); + + // Mount once + useEffect(() => { + if (!containerRef.current) return; + const term = new Terminal({ + convertEol: true, + disableStdin: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 12, + theme: { background: "transparent" }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(containerRef.current); + fit.fit(); + termRef.current = term; + fitRef.current = fit; + writtenLengthRef.current = 0; + return () => { + term.dispose(); + termRef.current = null; + fitRef.current = null; + }; + }, []); + + // Replay/incremental write on buffer change + useEffect(() => { + if (!termRef.current || !buffer) return; + const snap = buffer.snapshot(); + if (writtenLengthRef.current > snap.length) { + // Buffer was trimmed from head — re-render from scratch + termRef.current.clear(); + writtenLengthRef.current = 0; + } + for (let i = writtenLengthRef.current; i < snap.length; i += 1) { + termRef.current.writeln(snap[i]!); + } + writtenLengthRef.current = snap.length; + }, [buffer, buffer?.snapshot().length]); + + // Resize on container resize + useEffect(() => { + const handler = () => fitRef.current?.fit(); + window.addEventListener("resize", handler); + return () => window.removeEventListener("resize", handler); + }, []); + + // Reset on serverId change + useEffect(() => { + if (!termRef.current) return; + termRef.current.clear(); + writtenLengthRef.current = 0; + }, [serverId]); + + return
; +}; +``` + +- [ ] **Step 3: Replace stub in `DetectedServersPanel`** + +If a temporary stub was added in Task 26, swap it for the real import. + +- [ ] **Step 4: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/detectedServers/DetectedServerLogView.tsx +git commit -m "Add xterm.js-backed log view for detected servers" +``` + +--- + +### Task 28: Wire WS subscription in ChatView + +**Files:** + +- Modify: `apps/web/src/components/ChatView.tsx` (or wherever active-thread WS subscriptions live) + +- [ ] **Step 1: Inspect the existing terminal subscription site** + +Search for `terminal.onEvent` in `apps/web/src/components/`. The detected-servers subscription belongs next to it, scoped to the same thread-active lifecycle. + +- [ ] **Step 2: Add the subscription** + +```tsx +import { useEffect } from "react"; +import { rpcClient } from "../rpc/wsRpcClient.ts"; +import { useDetectedServerStore } from "../detectedServerStore.ts"; + +// Inside the component, where threadId is available: +useEffect(() => { + if (!threadId) return; + const handle = (event: DetectedServerEvent) => { + useDetectedServerStore.getState().handleEvent(threadId, event); + }; + return rpcClient.detectedServers.onEvent(threadId, handle); +}, [threadId]); +``` + +- [ ] **Step 3: Wire `openServersTab` callback to `BranchToolbar`** + +Make `ChatView` pass a `onOpenServersTab` callback that calls: + +```ts +useTerminalStateStore.getState().setTerminalOpen(threadKey, true); +useTerminalStateStore.getState().setTerminalDrawerKind(threadKey, "servers"); +``` + +`BranchToolbar` passes this through to `DetectedServersBadge` as `onClick`. + +- [ ] **Step 4: Run typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 5: Run the dev server and manually verify** + +Run: `bun run dev:web` (and `bun run dev:server` if separate) + +Manually: + +1. Open the web app. +2. Open a thread. +3. From the integrated terminal, run `npx vite` in a real Vite project, or use the agent to spawn a dev server. +4. Verify the badge appears in `BranchToolbar` with count `1` and pulses while `predicted`/`candidate`. +5. Click the badge → drawer opens with Servers tab active. +6. Confirm the server row shows framework=`vite`, status pill cycles to `live`, URL is clickable. +7. Click the URL → browser opens. +8. Click stop → server exits, status → `exited`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/ChatView.tsx apps/web/src/components/BranchToolbar.tsx +git commit -m "Subscribe to detectedServers events and wire badge → drawer tab transition" +``` + +--- + +## Phase 10 — Final gate + +### Task 29: Run the full quality gate + +**Files:** none. + +- [ ] **Step 1: Format** + +Run: `bun fmt` +Expected: no diff. + +- [ ] **Step 2: Lint** + +Run: `bun lint` +Expected: no warnings or errors. + +- [ ] **Step 3: Typecheck** + +Run: `bun run typecheck` +Expected: no errors across the entire monorepo. + +- [ ] **Step 4: Test** + +Run: `bun run test` +Expected: all tests PASS (including new unit, OS adapter, and integration tests). + +- [ ] **Step 5: Smoke test manually** + +Repeat the manual smoke test from Task 28 Step 5 against the agent flow: + +1. Open a Codex thread. +2. Approve a `bun run dev` (or `npm run dev`) command in a project whose `package.json` `scripts.dev` is `vite`. +3. Confirm a `candidate` server appears in the Servers tab with the agent's printed URL. +4. Confirm status remains `candidate` (does not progress to `live` — by design for agent-internal). +5. Confirm clicking the URL opens the browser. + +- [ ] **Step 6: Final commit (if any cleanup)** + +If `bun fmt` produced changes, commit them. Otherwise no commit. + +```bash +git status +# if any modified files: +git add -A +git commit -m "Format and lint cleanup" +``` + +--- + +## Notes for the engineer + +- **Effect 4.0 Service patterns**: this codebase uses `Context.Service()("namespace/Tag")` for service tags (see `apps/server/src/terminal/Services/PTY.ts:56-58`). Match that style. The newer `Effect.Service.builder` syntax is also accepted but not yet pervasive — prefer matching the file you're editing. +- **Schema check chaining**: the codebase uses `.check(Schema.isPattern(...))`, `.check(Schema.isMaxLength(...))`, etc., rather than `.pipe(Schema.filter(...))`. Match that style (see `packages/contracts/src/terminal.ts:13-16`). +- **WS push channel**: the existing `terminal.event` channel is the closest pattern. Search for it in `packages/contracts/src/ws.ts` and `apps/server/src/wsServer.ts` and mirror the wiring exactly. +- **AGENTS.md hard rules**: `bun run test`, never `bun test`. `bun fmt`, `bun lint`, `bun run typecheck` must pass before each commit. +- **Commit messages**: this codebase does not use Conventional Commits prefixes; one-line imperative summary is the norm (see `git log -10 --oneline`). +- **No Co-Authored-By trailers** in commit messages (per user preference). diff --git a/docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md b/docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md new file mode 100644 index 00000000000..ff49f49eb89 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md @@ -0,0 +1,532 @@ +# Auto-Detect Agent-Spawned Servers + +## Goal + +When a coding agent (Codex, Claude/ACP, OpenCode) or a local terminal PTY +spawns a dev server (Vite, Next, Nuxt, Astro, Remix, Wrangler, Vitest UI, +Webpack-DevServer, generic Express, HTTP/SSE MCP servers), surface it in the +web UI with: + +1. An "Active Servers" list scoped to the current thread. +2. The server's canonical URL, clickable to open in the browser. +3. A live, xterm.js-style tail of the server's log output. +4. A toolbar badge showing the number of running servers, pulsing while any + detection is still tentative. + +The user wants to know what their agent has running without leaving the +chat. + +## Non-goals + +- **stdio MCP servers** (those that talk JSON-RPC over stdin/stdout without + listening on any port). They warrant a different UI affordance and a + dedicated data model; deferred. +- **NODE_OPTIONS / LD_PRELOAD injection** for low-latency detection. + Skipped — too fragile across Bun/Deno/Python/Go runtimes. +- **Persistence across `apps/server` restart.** Registry is in-memory only; + PTY-sourced servers also vanish when the server restarts, so this is + mostly natural. +- **Cross-thread aggregation.** Servers are scoped to the thread they were + spawned in. +- **Sidebar route / dedicated full-page view.** Drawer tab is enough. +- **LAN-share URL surfacing** (Vite's `Network:` URL on `0.0.0.0` binds). + Recorded but not displayed in v1. +- **Auto-open browser on `live`.** Too invasive without user consent. +- **User-extensible regex/framework table.** Hardcoded list only. +- **Server-side log file persistence.** Terminals persist; detected-server + logs do not. +- **Granular stop for agent-internal servers.** Only "interrupt the turn". + By design — the agent owns the lifecycle. + +## Scope + +In scope: + +- New backend module `apps/server/src/detectedServers/` (Effect Services + + Layers) covering detection state machine, OS socket probing, stdout + sniffing, argv hinting, and liveness heartbeat. +- Schema-only additions to `packages/contracts/src/detectedServers.ts`. +- New WS push channel `detectedServers.event` and streaming RPC + `subscribeDetectedServerEvents`; new request RPCs `detectedServers.stop` + and `detectedServers.openInBrowser`. +- Read-only taps into three existing modules to feed the detector: + - `CodexSessionRuntime.ts` — Codex agent commands + - `AcpSessionRuntime.ts` — Claude/Cursor agent commands + - `terminal/Layers/Manager.ts` — local PTYs (user terminal + OpenCode) +- New web UI components: toolbar badge, "Servers" tab inside the existing + `ThreadTerminalDrawer`, per-server log view (xterm.js), list rows with + Open / Stop / Copy URL controls. +- New Zustand slice `detectedServerStore.ts`. +- Shared `packages/shared/src/lineBuffer.ts` extracted from the existing + terminal line-cap logic; reused by both terminals and detected servers. +- Unit + OS-adapter + integration test coverage as outlined in the Testing + section. + +Out of scope: everything in Non-goals. + +## Architecture + +### Module layout + +``` +apps/server/src/detectedServers/ + Services/ + DetectedServerRegistry.ts // public Service tag + API surface + Layers/ + Registry.ts // in-memory map, event publisher, state machine + ArgvHinter.ts // tokenize argv + package.json scripts → framework guess + StdoutSniffer.ts // ANSI-strip + ordered framework regex table + SocketProbe.ts // OS-agnostic facade + SocketProbe.Linux.ts // /proc//net/tcp parser, pure JS + SocketProbe.Darwin.ts // lsof -nP -iTCP -sTCP:LISTEN -a -p + SocketProbe.Windows.ts // netstat -ano filtered by pid + LivenessHeartbeat.ts // fetch HEAD probe with AbortSignal.timeout + DetectedServersIngress.ts // composes provider/PTY taps + __fixtures__/ // captured stdout from real frameworks +``` + +`DetectedServersIngress` is added to the application's layer graph in +`apps/server/src/serverLayers.ts`. The existing provider/terminal modules +expose new emitter taps (event-emit only — no behavior change). + +### Data flow + +``` +Agent tool call (Codex/ACP) Local PTY (terminal/OpenCode) + | | + v v + outputDelta notification drainProcessEvents + + requestApproval + pty.pid + | | + +-------+ +---------------+ + | | + v v + ArgvHinter StdoutSniffer ← strip-ansi + framework regex + | | + +---+ +-------+ + | | + v v + Registry ← state machine, identity keying + | + +-- SocketProbe (PTY path only, pidtree expansion) + +-- LivenessHeartbeat (post-confirmed) + | + v + publishes detectedServers.event on ServerPushBus + | + v + wsServer → Browser → detectedServerStore + → BranchToolbar badge + → ThreadTerminalDrawer "Servers" tab + → xterm.js log view +``` + +### State machine + +``` +predicted → candidate → confirmed → live → (restarting → live | exited | crashed) +predicted → exited (one-shot build that never opened a socket) +candidate → exited (URL printed but process exited before confirmation) +``` + +Transition rules (enforced in `Registry.registerOrUpdate`): + +- `predicted`: ArgvHinter says this command is likely a server. No URL yet. +- `candidate`: StdoutSniffer extracted a URL. Source `codex`/`acp` stops + here — there's no real pid to probe. +- `confirmed`: SocketProbe sees a `LISTEN` socket on the matching port (or + any port for silent servers without a sniffed URL). +- `live`: LivenessHeartbeat got any response from `fetch(url, { method: +"HEAD" })`. Most servers reach `live` ~100–500 ms after this. +- `restarting`: previously `live`, socket disappeared briefly but pid still + alive (Vite HMR restart). +- `exited` / `crashed`: terminal state. + +Exit triggers per source: + +- `pty`: the existing PTY `onExit` callback (`exitCode === 0` → `exited`, + non-zero → `crashed`), or sustained `lost-socket` from SocketProbe while + the pid is still alive (rare; treat as `exited` with `exitReason: +"lost-socket"`). +- `codex` / `acp`: the matching command-execution completion notification + on the provider's event stream. Without a process-exit signal we cannot + distinguish clean exit from crash; we record both as `exited` with + `exitReason` derived from the provider's reported result (success → + `"stopped"`, error → `"crashed"`). + +### Identity keying + +- Source `pty`: identity = `(threadId, pty.pid, port)`. Stable across Vite + HMR self-restarts. +- Source `codex` / `acp`: identity = `(threadId, turnId, itemId)`. + +### Push channel + +New channel `detectedServers.event` published via `ServerPushBus` (same +ordered delivery as `terminal.event`). Event union: + +``` +DetectedServerEvent = + | { type: "registered"; server: DetectedServer } + | { type: "updated"; serverId: string; patch: Partial } + | { type: "log"; serverId: string; data: string } + | { type: "removed"; serverId: string } +``` + +## Schema + +`packages/contracts/src/detectedServers.ts` (Effect Schema, schema-only): + +``` +ServerStatus = "predicted" | "candidate" | "confirmed" | "live" + | "restarting" | "exited" | "crashed" + +ServerSource = "codex" | "acp" | "pty" + +ServerFramework = "vite" | "next" | "nuxt" | "remix" | "astro" | "wrangler" + | "webpack" | "vitest-ui" | "storybook" | "mcp-http" + | "express" | "unknown" + +DetectedServer = { + id: string // ulid + threadId: string + source: ServerSource + framework: ServerFramework + status: ServerStatus + url?: string // canonical click-through + port?: number + host?: string // "localhost" | "127.0.0.1" | "0.0.0.0" | "[::1]" + pid?: number // present only when source = "pty" + argv?: ReadonlyArray + cwd?: string + startedAt: Date // first predicted/candidate signal + liveAt?: Date // first time fetch returned anything + lastSeenAt: Date + exitedAt?: Date + exitReason?: "stopped" | "crashed" | "lost-socket" +} +``` + +New RPC methods on `NativeApi`: + +- `subscribeDetectedServerEvents(threadId)` — streaming +- `detectedServers.stop(serverId)` — request/response; returns + `{ kind: "stopped" } | { kind: "not-stoppable"; hint: "interrupt-turn" }` +- `detectedServers.openInBrowser(serverId)` — request/response; routes + through existing `apps/server/src/open.ts` + +## Detection pipeline + +### ArgvHinter + +Pure synchronous + one cached `package.json` read per cwd. + +- Token table → framework hint: `vite`, `next`, `nuxt`, `astro`, `remix`, + `wrangler dev`, `bun run dev`, `npm run dev`, `pnpm dev`, `yarn dev`, + `vitest --ui`, `storybook dev`, etc. +- Indirect invocations: `npm/bun/pnpm/yarn run ` → read + `/package.json` `scripts.` and re-tokenize (one level only). +- Build/test denylist: `build`, `test`, `tsc`, `eslint`, `prettier`, + `vitest run` (without `--ui`), `playwright test` → `isLikelyServer = +false`. +- Unknown but `dev`/`serve`/`start`/`watch` token → `framework = "unknown", +isLikelyServer = true`. + +### StdoutSniffer + +- Buffer chunks until `\n` or 64KB. +- Strip ANSI with inline regex `\x1b\[[0-9;]*[a-zA-Z]` (avoids a new + dependency). +- Collapse whitespace within each line. +- Run ordered regex table (Vite → Next → Nuxt → Astro → Remix → Wrangler → + Webpack-DevServer → generic loopback). +- First match per line wins; emit `updated` with `status = "candidate"`, + `url`, `port`, `host`, `framework`. + +Generic loopback regex: + +``` +\bhttps?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::\d+)?(?:/\S*)?\b +``` + +### SocketProbe + +`probe(pids: number[]): Effect` returning rows of `{ pid, +port, host }` for sockets in `LISTEN` state owned by any pid in the set. +Process-tree expansion via `pidtree` library (new dep) so Vite's esbuild +worker and Next's worker pool are covered. + +OS adapters (selected at layer construction): + +- **Linux**: `/proc//fd/*` symlink scan for `socket:[]`, + cross-referenced against `/proc//net/tcp` + `tcp6` rows with `st = +0A`. Pure JS, ~5 ms per pid. +- **Darwin**: shell out to `lsof -nP -iTCP -sTCP:LISTEN -a -p ` + and parse. ~150–400 ms. +- **Windows**: shell out to `netstat -ano` and filter pid column. ~300–800 + ms. + +If the per-OS binary is absent at layer init, log once and `probe()` +returns `[]` forever. Detection degrades to stdout-regex only. + +Polling cadence: 250 ms during the first 30 s of a `predicted`/`candidate` +server, then 2 s once `live`. Stops on `exited`/`crashed`. Fibers owned by +the registry, interrupted by the layer finalizer. + +Debugger-port denylist: `9229`, `9230`, plus any port matched by +`--inspect(-brk|-wait)?=([0-9]+)` regex in argv. + +### LivenessHeartbeat + +After `confirmed`: `fetch(url, { signal: AbortSignal.timeout(500), method: +"HEAD" })` every 5 s. Any response (2xx/3xx/4xx/5xx) → `live`. Failure: +re-run SocketProbe; if socket gone → `exited` (`exitReason: "lost-socket"`); +if still present → leave as-is (HEAD-unsupported server). + +### Registry + +Owns `Map`. Single mutation entry point +`registerOrUpdate(input)`. Enforces: + +- Only legal state transitions. +- Identity-keying rules (recognise restart vs new identity). +- Per-server probe-fiber lifecycle. +- Event emission on every mutation. + +Public reads: `subscribe(threadId)`, `getCurrent(threadId)` (used to replay +state to newly-subscribing clients). + +### Per-provider wiring + +- **Codex** (`CodexSessionRuntime.ts`): + - On `item/commandExecution/requestApproval` with `requestKind: +"command"` → `ArgvHinter.hint(payload.argv, payload.cwd)`; if + `isLikelyServer`, register with `source: "codex"`, `status: +"predicted"`. + - On `item/commandExecution/outputDelta` for the matching `(turnId, +itemId)` → feed `StdoutSniffer`. + - No SocketProbe (no real pid). + +- **ACP** (`AcpSessionRuntime.ts`): mirrors the Codex hooks on the ACP + event shape. + +- **PTY** (`terminal/Layers/Manager.ts`): + - On PTY spawn → `ArgvHinter` from `terminalState.argv`. + - On each output chunk in `drainProcessEvents` → feed `StdoutSniffer`. + - Pass `pty.pid` to `SocketProbe`; begin polling. + - All four signals (hint, stdout, socket, heartbeat) active. + +## Web UI + +### Toolbar badge + +`apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx` (new), +slotted into the existing `BranchToolbar.tsx` to the right of the terminal +toggle. + +- Hidden when no detected servers for the current thread. +- Lucide `Server` icon + numeric count. +- Pulse-dot styling reused from `ChatSessionTabs` `in_progress` while any + server is `predicted` or `candidate`. +- Tooltip lists each server: `framework · url · status`. +- Click → opens drawer with the Servers tab active. + +### Drawer tab + +`ThreadTerminalDrawer.tsx` (modified) gains a top-level **kind tabset** +(`Terminals` / `Servers`). The existing terminal tab system stays inside +the `Terminals` kind unchanged. + +Inside the `Servers` kind: `apps/web/src/components/detectedServers/`: + +- `DetectedServersPanel.tsx` — left rail (list) + main area (log view). +- `DetectedServerRow.tsx` — framework icon, URL (or `[stdio]` placeholder + for non-URL), status pill, age. Hover-controls: Open, Stop, Copy URL. +- `DetectedServerLogView.tsx` — xterm.js mount, mirrors the existing + terminal-drawer write pattern; subscribes to `log` events from the store. + +Empty state: muted message "No servers detected yet. They'll appear here +when an agent runs `dev`/`serve` commands." + +### State + +New store `apps/web/src/detectedServerStore.ts` (Zustand): + +``` +{ + serversByThreadKey: Record> + logBuffersByServerId: Map // 5000-line cap + activeServerIdByThreadKey: Record +} +``` + +`terminalStateStore` gains one field per thread: +`terminalDrawerKind: "terminals" | "servers"` (default `"terminals"`), +persisted to localStorage alongside drawer height. + +### WS subscription + +`apps/web/src/rpc/wsRpcClient.ts` gains +`subscribeDetectedServerEvents(threadId, listener) → unsubscribe`, +mirroring the existing `terminal.onEvent` pattern. `ChatView` subscribes in +`useEffect` on the active thread, dispatches to `detectedServerStore`. + +### Stop semantics + +- Source `pty`: `Registry.stop` → `terminalManager.stopProcess(pid)` + (SIGTERM→SIGKILL via existing escalation). +- Source `codex`/`acp`: RPC returns `{ kind: "not-stoppable", hint: +"interrupt-turn" }`. UI shows inline tooltip "This server is managed by + the agent — interrupt the current turn to stop it" with a button calling + `providers.interruptTurn`. + +## Edge cases + +- **Tunnel clients** (ngrok, cloudflared, Tailscale serve): SocketProbe + filters strictly on `LISTEN`. StdoutSniffer URLs do not auto-promote to + `live` for source `pty` without socket confirmation. +- **Debugger ports**: hard denylist + argv regex. +- **One-shot builds**: ArgvHinter denylist catches `build`/`test`/`tsc` + up front; belt-and-braces: predicted + no socket + no URL after 15 s + + exit → silently discard (no `removed` event published since nothing was + ever surfaced beyond predicted). +- **Vite HMR self-restart**: same pid + same port → `live → restarting → +live` on the same `serverId`. +- **Page reload**: on `subscribeDetectedServerEvents`, the server first + yields one synthetic `registered` event per current server in that + thread (snapshot from `Registry.getCurrent(threadId)`), then streams + incremental events. Mirrors how terminal `open()` returns a snapshot + followed by event subscription. No backwards `log`-history replay in v1 + — late subscribers see only logs from the moment they connect. +- **Server restart**: registry wiped; PTYs also wiped → no orphans. +- **OS adapter absent**: graceful degradation to stdout-only detection. +- **URL canonicalization**: prefer the framework-printed host; fall back to + `http://localhost:` for socket-only discovery. `0.0.0.0` displayed + as `http://localhost:` with bind host recorded separately. +- **HTTP/SSE MCP**: detected via the same pipeline; `framework: "mcp-http"` + if the URL path contains `/sse` or `/mcp`, or a `GET /` probe response + carries `text/event-stream`. (The probe is part of LivenessHeartbeat for + this framework only — avoids per-framework heartbeat sprawl.) + +## Testing + +Vitest. `bun run test` (per AGENTS.md — never `bun test`). + +### Unit (no I/O) + +- `ArgvHinter.test.ts` — table-driven coverage of all listed frameworks + + build/test denylist + indirect `npm/bun run` re-scan path (using a + `MemoryFileSystem` Effect layer for the package.json read). +- `StdoutSniffer.test.ts` — fixture-driven; real captured stdout in + `__fixtures__/`. One fixture per framework. Verifies ANSI strip, + split-across-chunk assembly, regex precedence. +- `Registry.test.ts` — state-machine transitions (legal and illegal); + identity-keying for restart vs new identity. +- `LineBuffer.test.ts` (in `packages/shared`) — cap behavior, head-trim, + multi-byte safety. + +### OS adapters (mocked) + +- `SocketProbe.Linux.test.ts` — fixture `/proc//net/tcp` and + `tcp6` text, inode → fd cross-reference. +- `SocketProbe.Darwin.test.ts` — mocked `lsof` output strings. +- `SocketProbe.Windows.test.ts` — mocked `netstat -ano` output strings. + +### Integration + +- `apps/server/integration/detectedServersPty.integration.test.ts` — spawn + a real tiny Node HTTP server inside `TerminalManager`; assert the + pipeline produces `predicted → candidate → confirmed → live` via the WS + push channel. +- `apps/server/integration/detectedServersCodex.integration.test.ts` — + drive `TestProviderAdapter` with synthetic `outputDelta` notifications + mimicking Vite output; assert `predicted → candidate` (no `live` is + correct for agent-internal). + +### Web + +- `detectedServerStore.test.ts` — reducer-style tests for event handling. +- `DetectedServersBadge.test.tsx` — 0 / 1 / many; pulsing class. + +### Excluded + +- Real-network LivenessHeartbeat tests. +- Cross-OS native SocketProbe (each adapter tested via mocks; native runs + only in whatever runner is current). +- xterm.js DOM correctness (covered by existing terminal tests). + +### Gate + +`bun fmt`, `bun lint`, `bun run typecheck`, `bun run test` all green. + +## Dependencies + +- New runtime dep: `pidtree` (~30 KB, no native code). +- No new web deps. +- No new dev deps. + +## File-level summary + +``` +apps/server/src/detectedServers/ + Services/DetectedServerRegistry.ts (new) + Layers/Registry.ts (new) + Layers/ArgvHinter.ts (new) + Layers/StdoutSniffer.ts (new) + Layers/SocketProbe.ts (new) + Layers/SocketProbe.Linux.ts (new) + Layers/SocketProbe.Darwin.ts (new) + Layers/SocketProbe.Windows.ts (new) + Layers/LivenessHeartbeat.ts (new) + Layers/DetectedServersIngress.ts (new) + __fixtures__/* (new) + ArgvHinter.test.ts, StdoutSniffer.test.ts, Registry.test.ts (new) + SocketProbe.{Linux,Darwin,Windows}.test.ts (new) + +apps/server/src/ + serverLayers.ts (modified: + ingress) + ws.ts (modified: + RPC handlers) + provider/Layers/CodexSessionRuntime.ts (modified: + emitter taps) + provider/acp/AcpSessionRuntime.ts (modified: + emitter taps) + terminal/Layers/Manager.ts (modified: + emitter taps) + +apps/server/integration/ + detectedServersPty.integration.test.ts (new) + detectedServersCodex.integration.test.ts (new) + +packages/contracts/src/ + detectedServers.ts (new) + rpc.ts (modified: + 3 RPCs) + ws.ts (modified: + 1 channel) + +packages/shared/src/ + lineBuffer.ts (new — extracted shared util) + lineBuffer.test.ts (new) + +apps/web/src/ + detectedServerStore.ts (new) + detectedServerStore.test.ts (new) + rpc/wsRpcClient.ts (modified: + subscribe) + components/BranchToolbar.tsx (modified: + badge slot) + components/BranchToolbar/DetectedServersBadge.tsx (new) + components/BranchToolbar/DetectedServersBadge.test.tsx (new) + components/ThreadTerminalDrawer.tsx (modified: + kind tabset) + components/detectedServers/DetectedServersPanel.tsx (new) + components/detectedServers/DetectedServerRow.tsx (new) + components/detectedServers/DetectedServerLogView.tsx (new) +``` + +## Open questions + +None blocking — all design forks resolved during brainstorming. + +## References + +- Architecture: `.docs/architecture.md` +- Provider architecture: `.docs/provider-architecture.md` +- Existing terminal infrastructure: `apps/server/src/terminal/`, + `apps/web/src/components/ThreadTerminalDrawer.tsx` +- Existing process detection primitives: + `apps/server/src/terminal/Services/Manager.ts` (subprocess polling) +- Push channel pattern: `packages/contracts/src/ws.ts`, + `apps/server/src/wsServer/pushBus.ts` diff --git a/packages/contracts/src/detectedServers.ts b/packages/contracts/src/detectedServers.ts new file mode 100644 index 00000000000..ccb55a51500 --- /dev/null +++ b/packages/contracts/src/detectedServers.ts @@ -0,0 +1,130 @@ +import { Schema } from "effect"; + +export const ServerStatus = Schema.Literals([ + "predicted", + "candidate", + "confirmed", + "live", + "restarting", + "exited", + "crashed", +]); +export type ServerStatus = typeof ServerStatus.Type; + +export const ServerSource = Schema.Literals(["codex", "acp", "pty"]); +export type ServerSource = typeof ServerSource.Type; + +export const ServerFramework = Schema.Literals([ + "vite", + "next", + "nuxt", + "remix", + "astro", + "wrangler", + "webpack", + "vitest-ui", + "storybook", + "mcp-http", + "express", + "unknown", +]); +export type ServerFramework = typeof ServerFramework.Type; + +export const ExitReason = Schema.Literals(["stopped", "crashed", "lost-socket"]); +export type ExitReason = typeof ExitReason.Type; + +export const DetectedServer = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty()), + threadId: Schema.String.check(Schema.isNonEmpty()), + source: ServerSource, + framework: ServerFramework, + status: ServerStatus, + url: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + host: Schema.optional(Schema.String), + pid: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + terminalId: Schema.optional(Schema.String), + argv: Schema.optional(Schema.Array(Schema.String)), + cwd: Schema.optional(Schema.String), + startedAt: Schema.DateTimeUtc, + liveAt: Schema.optional(Schema.DateTimeUtc), + lastSeenAt: Schema.DateTimeUtc, + exitedAt: Schema.optional(Schema.DateTimeUtc), + exitReason: Schema.optional(ExitReason), +}); +export type DetectedServer = typeof DetectedServer.Type; + +const DetectedServerEventBase = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), + createdAt: Schema.String, +}); + +const RegisteredEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("registered"), + server: DetectedServer, +}); + +const UpdatedEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("updated"), + serverId: Schema.String.check(Schema.isNonEmpty()), + patch: Schema.Struct({ + status: Schema.optional(ServerStatus), + framework: Schema.optional(ServerFramework), + url: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + host: Schema.optional(Schema.String), + pid: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + terminalId: Schema.optional(Schema.String), + liveAt: Schema.optional(Schema.DateTimeUtc), + lastSeenAt: Schema.optional(Schema.DateTimeUtc), + exitedAt: Schema.optional(Schema.DateTimeUtc), + exitReason: Schema.optional(ExitReason), + }), +}); + +const LogEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("log"), + serverId: Schema.String.check(Schema.isNonEmpty()), + data: Schema.String, +}); + +const RemovedEvent = Schema.Struct({ + ...DetectedServerEventBase.fields, + type: Schema.Literal("removed"), + serverId: Schema.String.check(Schema.isNonEmpty()), +}); + +export const DetectedServerEvent = Schema.Union([ + RegisteredEvent, + UpdatedEvent, + LogEvent, + RemovedEvent, +]); +export type DetectedServerEvent = typeof DetectedServerEvent.Type; + +export const DetectedServerStopInput = Schema.Struct({ + serverId: Schema.String.check(Schema.isNonEmpty()), +}); +export type DetectedServerStopInput = typeof DetectedServerStopInput.Type; + +export const DetectedServerStopResult = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("stopped") }), + Schema.Struct({ + kind: Schema.Literal("not-stoppable"), + hint: Schema.Literal("interrupt-turn"), + }), +]); +export type DetectedServerStopResult = typeof DetectedServerStopResult.Type; + +export const DetectedServerOpenInBrowserInput = Schema.Struct({ + serverId: Schema.String.check(Schema.isNonEmpty()), +}); +export type DetectedServerOpenInBrowserInput = typeof DetectedServerOpenInBrowserInput.Type; + +export const SubscribeDetectedServerEventsInput = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), +}); +export type SubscribeDetectedServerEventsInput = typeof SubscribeDetectedServerEventsInput.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b83e7012167..04658013890 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export * from "./auth.ts"; export * from "./environment.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; +export * from "./detectedServers.ts"; export * from "./terminal.ts"; export * from "./provider.ts"; export * from "./providerInstance.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0c6bfcdbcf3..78cb5e3d400 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -110,6 +110,13 @@ import { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; +import { + DetectedServerEvent, + DetectedServerOpenInBrowserInput, + DetectedServerStopInput, + DetectedServerStopResult, + SubscribeDetectedServerEventsInput, +} from "./detectedServers.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -261,6 +268,11 @@ export const WS_METHODS = { subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", + subscribeDetectedServerEvents: "subscribeDetectedServerEvents", + + // Detected server methods + detectedServersStop: "detectedServers.stop", + detectedServersOpenInBrowser: "detectedServers.openInBrowser", } as const; export const GitCreateWorktreeForProjectInput = Schema.Struct({ @@ -908,6 +920,25 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, stream: true, }); +export const WsSubscribeDetectedServerEventsRpc = Rpc.make( + WS_METHODS.subscribeDetectedServerEvents, + { + payload: SubscribeDetectedServerEventsInput, + success: DetectedServerEvent, + stream: true, + }, +); + +export const WsDetectedServersStopRpc = Rpc.make(WS_METHODS.detectedServersStop, { + payload: DetectedServerStopInput, + success: DetectedServerStopResult, +}); + +export const WsDetectedServersOpenInBrowserRpc = Rpc.make(WS_METHODS.detectedServersOpenInBrowser, { + payload: DetectedServerOpenInBrowserInput, + success: Schema.Struct({ ok: Schema.Boolean }), +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, @@ -987,6 +1018,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, + WsSubscribeDetectedServerEventsRpc, + WsDetectedServersStopRpc, + WsDetectedServersOpenInBrowserRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 4342c01d476..fc05e48bf33 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -87,6 +87,10 @@ "./sourceControlContextFormatter": { "types": "./src/sourceControlContextFormatter.ts", "import": "./src/sourceControlContextFormatter.ts" + }, + "./lineBuffer": { + "types": "./src/lineBuffer.ts", + "import": "./src/lineBuffer.ts" } }, "scripts": { diff --git a/packages/shared/src/lineBuffer.test.ts b/packages/shared/src/lineBuffer.test.ts new file mode 100644 index 00000000000..1d6e6b21238 --- /dev/null +++ b/packages/shared/src/lineBuffer.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { LineBuffer } from "./lineBuffer.ts"; + +describe("LineBuffer", () => { + it("appends chunks and yields complete lines on flush", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("hello\nworld\n"); + expect(buf.snapshot()).toEqual(["hello", "world"]); + }); + + it("retains incomplete trailing fragment until flush", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("hello\nwor"); + buf.write("ld\n"); + expect(buf.snapshot()).toEqual(["hello", "world"]); + }); + + it("trims head when maxLines exceeded", () => { + const buf = new LineBuffer({ maxLines: 2 }); + buf.write("a\nb\nc\nd\n"); + expect(buf.snapshot()).toEqual(["c", "d"]); + }); + + it("clear() empties the buffer", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("a\nb\n"); + buf.clear(); + expect(buf.snapshot()).toEqual([]); + }); + + it("snapshot() returns a defensive copy", () => { + const buf = new LineBuffer({ maxLines: 100 }); + buf.write("a\n"); + const snap = buf.snapshot(); + snap.push("mutation"); + expect(buf.snapshot()).toEqual(["a"]); + }); +}); diff --git a/packages/shared/src/lineBuffer.ts b/packages/shared/src/lineBuffer.ts new file mode 100644 index 00000000000..c15083bd2ce --- /dev/null +++ b/packages/shared/src/lineBuffer.ts @@ -0,0 +1,39 @@ +export interface LineBufferOptions { + readonly maxLines: number; +} + +/** + * Rolling line buffer. Appends arbitrary chunks, splits on \n, retains an + * incomplete trailing fragment for the next write, and trims from the head + * when maxLines is exceeded. + */ +export class LineBuffer { + private lines: string[] = []; + private fragment = ""; + private readonly maxLines: number; + + constructor(options: LineBufferOptions) { + this.maxLines = options.maxLines; + } + + write(chunk: string): void { + if (chunk.length === 0) return; + const combined = this.fragment + chunk; + const parts = combined.split("\n"); + this.fragment = parts.pop() ?? ""; + if (parts.length === 0) return; + this.lines.push(...parts); + if (this.lines.length > this.maxLines) { + this.lines.splice(0, this.lines.length - this.maxLines); + } + } + + snapshot(): string[] { + return this.lines.slice(); + } + + clear(): void { + this.lines = []; + this.fragment = ""; + } +}