Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
1bf19fe
Add spec for auto-detecting agent-spawned servers
sak0a May 13, 2026
68a9fdb
Add implementation plan for auto-detecting agent-spawned servers
sak0a May 13, 2026
e5b45b3
Add pidtree dependency for process-tree socket probing
sak0a May 13, 2026
e6b975d
Add LineBuffer shared utility for rolling line storage
sak0a May 13, 2026
a6fb9bf
Add DetectedServer contracts schema
sak0a May 13, 2026
21270a3
Wire detectedServers WS channel and RPC methods into contracts
sak0a May 13, 2026
25b0e1e
Add ArgvHinter for framework detection from spawn argv
sak0a May 13, 2026
7df416a
Tighten ArgvHinter framework matching and document one-level script e…
sak0a May 13, 2026
4ae0306
Add StdoutSniffer for framework URL extraction from spawn output
sak0a May 13, 2026
c44d4b6
Add Registry with state-machine validation and event publishing
sak0a May 13, 2026
8d89466
Add SocketProbe service tag
sak0a May 13, 2026
7866ca5
Add Linux SocketProbe via /proc/<pid>/net/tcp parsing
sak0a May 13, 2026
38b3567
Add Darwin SocketProbe via lsof
sak0a May 13, 2026
5bab884
Add Windows SocketProbe via netstat -ano
sak0a May 13, 2026
6482a0b
Add runtime OS-selecting SocketProbe layer
sak0a May 13, 2026
ed120cf
Add LivenessHeartbeat service for HEAD-probe checks
sak0a May 13, 2026
f1a07b4
Add DetectedServerRegistry Effect Service wrapping the in-memory Regi…
sak0a May 13, 2026
1ccb220
Compose DetectedServersIngress with probe, heartbeat, and registry
sak0a May 13, 2026
e30ac67
Fix circular import in SocketProbe, sniffer listener leak, and Effect…
sak0a May 13, 2026
b1bcae5
Tap Codex command execution events for detected-server tracking
sak0a May 13, 2026
44b2ad4
Tap ACP command execution events for detected-server tracking
sak0a May 13, 2026
2f94740
Tap local PTY events for detected-server tracking
sak0a May 13, 2026
26be472
Wire detectedServers RPC handlers (subscribe, stop, openInBrowser)
sak0a May 13, 2026
c77f1c7
Add Codex synthetic integration test for detected servers
sak0a May 13, 2026
04eef60
Add PTY real-server integration test for detected servers
sak0a May 13, 2026
63db462
Add detectedServerStore Zustand slice
sak0a May 13, 2026
36b20f4
Add detectedServers subscription to WS RPC client
sak0a May 13, 2026
a2365d3
Add DetectedServersBadge to BranchToolbar
sak0a May 13, 2026
d47465c
Add kind tabset (Terminals / Servers) to ThreadTerminalDrawer
sak0a May 13, 2026
5c9cf78
Add DetectedServersPanel and DetectedServerRow
sak0a May 13, 2026
25c8846
Add xterm.js-backed log view for detected servers
sak0a May 13, 2026
40a9ff7
Subscribe to detectedServers events and wire badge → drawer tab trans…
sak0a May 13, 2026
306b964
Polish auto-detect agent server docs
sak0a May 13, 2026
c440d52
Wire real PTY kill for detectedServers.stop
claude May 13, 2026
81ee559
Detect dev servers from commands typed into the PTY
claude May 13, 2026
3102621
Dedup ACP cumulative detail when feeding StdoutSniffer
claude May 13, 2026
aec2d8b
Use tagged errors in detectedServers and bridge runtime via Runtime.r…
claude May 13, 2026
eac6b41
Expand test coverage for detectedServers pipeline and UI
claude May 13, 2026
c3098dd
Cast scoped-thread args to branded EnvironmentId/ThreadId
sak0a May 14, 2026
3fb455a
Merge pull request #63 from sak0a/claude/fix-agent-detection-tests-XNm4x
sak0a May 14, 2026
128cfeb
Merge origin/main into feature/auto-detect-agent-servers
sak0a May 14, 2026
fa006ac
Address CodeRabbit feedback and finish @ryco package rename
sak0a May 14, 2026
469936e
Merge branch 'feature/auto-detect-agent-servers' of https://github.co…
sak0a May 14, 2026
274a9e5
Fix oxfmt formatting in detectedServersCodex integration test
sak0a May 14, 2026
4ab6a1b
Record scheduled task lock
sak0a May 14, 2026
a2e6d99
Finish @s3tools/ -> @ryco/ rename in detected-servers test files
sak0a May 14, 2026
13f0771
Fix infinite render loop in BranchToolbar detected-servers selector
sak0a May 14, 2026
05aa30d
Pick heartbeat-alive port in probe loop instead of first candidate
sak0a May 14, 2026
4bd232c
Remove stale Claude scheduled tasks lock
sak0a May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions apps/server/integration/detectedServersCodex.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
});
});
79 changes: 79 additions & 0 deletions apps/server/integration/detectedServersPty.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"effect": "catalog:",
"node-pty": "^1.1.0",
"open": "^10.1.0",
"pidtree": "^0.6.0",
"sharp": "^0.34.5"
},
"devDependencies": {
Expand Down
97 changes: 97 additions & 0 deletions apps/server/src/detectedServers/ArgvHinter.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading