Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/bright-owls-converge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Next.js, Nuxt, and SvelteKit development integrations now resolve the shared Eve dev server for an app root. They attach only to healthy loopback owners, so each app root converges on one server.
5 changes: 5 additions & 0 deletions .changeset/reconnect-dev-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Running `eve dev` interactively now reconnects to the healthy loopback dev server recorded for the same app root, with a fresh session for each attached terminal UI. Eve replaces stale or malformed state when it starts a new server. `--host`, `--port`, or `PORT` skips reconnection and reports a healthy recorded server instead.
2 changes: 1 addition & 1 deletion docs/guides/frontend/nextjs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ For a public demo, use `none()` (also from `eve/channels/auth`) to skip authenti

## Dev vs deploy topology

- **Local dev.** `npm run dev` boots the eve dev server next to `next dev` and rewrites the eve routes over to it. The browser only ever talks to the Next.js origin.
- **Local dev.** `npm run dev` boots the eve dev server next to `next dev` and rewrites the eve routes over to it. If the same app root already has a server from `eve dev` or another framework process, Next.js reuses it. The browser only ever talks to the Next.js origin.
- **Vercel.** The web app and the eve runtime deploy as a single project. The web app stays public; the eve runtime sits behind it on the same site origin. When the agent needs its own build step, set `eveBuildCommand`:

```ts
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/frontend/nuxt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ For a public demo, use `none()` (also from `eve/channels/auth`) to skip authenti

## Dev vs deploy topology

- **Local dev.** `npm run dev` starts the eve dev server next to `nuxt dev` and proxies the eve routes through it. As far as the browser knows, everything is the Nuxt origin.
- **Local dev.** `npm run dev` starts the eve dev server next to `nuxt dev` and proxies the eve routes through it. If the same app root already has a server from `eve dev` or another framework process, Nuxt reuses it. As far as the browser knows, everything is the Nuxt origin.
- **Vercel.** A single Vercel project carries both the Nuxt app and the eve runtime. The web app stays public; the runtime sits behind it on the same origin. Set `eveBuildCommand` when the agent needs its own build step:

```ts
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/frontend/sveltekit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ For a public demo, use `none()` (also from `eve/channels/auth`) to skip authenti

## Dev vs deploy topology

- **Local dev.** `npm run dev` boots the eve dev server next to SvelteKit and proxies the eve routes to it, so the browser only ever hits the SvelteKit origin. `npm run build && npm run preview` behaves the same way: the preview server gets its own eve route proxy and either reuses the shared eve server or starts one.
- **Local dev.** `npm run dev` boots the eve dev server next to SvelteKit and proxies the eve routes to it, so the browser only ever hits the SvelteKit origin. If the same app root already has a server from `eve dev` or another framework process, SvelteKit reuses it. `npm run build && npm run preview` behaves the same way: the preview server gets its own eve route proxy and either reuses the shared eve server or starts one.
- **Vercel.** The SvelteKit app and the eve runtime deploy as a single project. The web app is public; the eve runtime sits behind it on the same origin. Use `eveBuildCommand` for a project-specific agent build:

```ts
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Pass a bare URL as the only argument and the UI connects to that server instead
| `--context-size <tokens>` | number | none | Model context window size, shown as a usage percentage |
| `--logs <mode>` | enum | `stderr` | Server/agent logs to show: `all` \| `stderr` \| `sandbox` \| `none` |

Local dev writes the active server process ID to `.eve/dev-process.pid`. If another `eve dev` starts for the same agent while that process is still running, eve exits with a message that includes the command to stop the existing server.
Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead.

Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`.

Expand Down
12 changes: 12 additions & 0 deletions packages/eve/src/cli/dev/tui/status-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ function deployedRemote(
}

describe("buildStatusLine", () => {
it("renders the local server port as a gray reverse-video badge before the model", () => {
const line = buildStatusLine({
serverPort: "3000",
model: "openai/gpt-5.5",
theme,
width: 120,
})!;

expect(stripAnsi(line)).toBe(" :3000 · openai/gpt-5.5");
expect(line).toContain("\x1b[7m\x1b[90m :3000 \x1b[39m\x1b[27m");
});

it("renders all segments in order with dot separators", () => {
const line = buildStatusLine({
model: "anthropic/claude-sonnet-4-6",
Expand Down
20 changes: 16 additions & 4 deletions packages/eve/src/cli/dev/tui/status-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { VercelStatusSnapshot } from "./vercel-status.js";
import type { ModelEndpointStatus } from "#shared/model-endpoint-status.js";

export interface StatusLineInput {
/** Port of the connected local development server; omitted for remote sessions. */
serverPort?: string;
/** Resolved model slug, e.g. "anthropic/claude-sonnet-4-6"; absent when `/eve/v1/info` failed. */
model?: string;
/** Preformatted token-flow segment (formatTokenFlow output), e.g. `↑ 394.4K ↓ 4.3K`. */
Expand Down Expand Up @@ -37,6 +39,14 @@ function renderModel(
return input.theme.colors.dim(model);
}

function renderServerPort(
input: Pick<StatusLineInput, "remote" | "serverPort" | "theme">,
): string | undefined {
if (input.remote !== undefined || input.serverPort === undefined) return undefined;
const c = input.theme.colors;
return c.inverse(c.gray(` :${input.serverPort} `));
}

function renderEndpoint(
input: Pick<StatusLineInput, "endpoint" | "remote" | "theme" | "vercel">,
): string | undefined {
Expand All @@ -51,7 +61,7 @@ function renderEndpoint(
}

/**
* Builds `↗ project (environment) · model · tokens · /deploy pending`.
* Builds `↗ project (environment) · :port · model · tokens · /deploy pending`.
* Remote sessions omit endpoint state and keep their badge as the final
* narrow-width fallback. Returns undefined when every segment is empty.
*/
Expand All @@ -60,6 +70,7 @@ export function buildStatusLine(input: StatusLineInput): string | undefined {
const c = theme.colors;

const logLevel = input.logLevel === undefined ? undefined : c.cyan(`logs: ${input.logLevel}`);
const serverPort = renderServerPort(input);
const model = renderModel(input);
const tokens = input.tokens === undefined ? undefined : c.dim(input.tokens);
const pending = input.vercel?.pendingDeploy ? c.yellow("/deploy pending") : undefined;
Expand All @@ -74,9 +85,10 @@ export function buildStatusLine(input: StatusLineInput): string | undefined {
// leads every variant and gets the final stand-alone fallback. Without a
// remote, the logs hint retains its previous priority.
const variants = [
compose([remote?.full, logLevel, model, tokens, endpoint, pending]),
compose([remote?.full, logLevel, model, tokens, pending]),
compose([remote?.full, logLevel, tokens, pending]),
compose([remote?.full, logLevel, serverPort, model, tokens, endpoint, pending]),
compose([remote?.full, logLevel, serverPort, model, tokens, pending]),
compose([remote?.full, logLevel, serverPort, tokens, pending]),
compose([remote?.full, logLevel, serverPort]),
compose([remote?.full, logLevel]),
compose([remote?.badge, logLevel]),
compose([remote?.badge]),
Expand Down
4 changes: 4 additions & 0 deletions packages/eve/src/cli/dev/tui/terminal-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2714,7 +2714,11 @@ describe("TerminalRenderer status line", () => {
const promptRow = lines.findIndex((line) => line.includes("❯"));
expect(promptRow).toBeGreaterThan(-1);
const statusRow = lines.slice(promptRow + 1).join("\n");
expect(statusRow).toContain(":3000");
expect(statusRow).toContain("anthropic/claude-sonnet-4-6");
expect(statusRow.indexOf(":3000")).toBeLessThan(
statusRow.indexOf("anthropic/claude-sonnet-4-6"),
);
// The linked project folds into the connected gateway label.
expect(statusRow).toContain("AI Gateway (my-agent)");
expect(statusRow).not.toContain("⚠ AI Gateway");
Expand Down
7 changes: 6 additions & 1 deletion packages/eve/src/cli/dev/tui/terminal-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2808,7 +2808,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
}

/**
* Appends the persistent bottom status line (model · tokens · Vercel link ·
* Appends the persistent bottom status line (port · model · tokens · Vercel link ·
* pending deploy) when any segment has content.
*/
#pushStatusLine(rows: string[], width: number): void {
Expand All @@ -2819,6 +2819,11 @@ export class TerminalRenderer implements AgentTUIRenderer {
width: contentWidth,
};
if (this.#logLevelHintActive) input.logLevel = this.#logs;
const serverUrl = this.#agentHeader?.serverUrl;
if (serverUrl !== undefined && this.#remoteConnection === undefined) {
const serverPort = new URL(serverUrl).port;
if (serverPort.length > 0) input.serverPort = serverPort;
}
const model = this.#agentHeader?.info?.agent.model.id;
if (model !== undefined) input.model = model;
// The runner resolves model-provider state with `/info` before caching this
Expand Down
43 changes: 43 additions & 0 deletions packages/eve/src/cli/dev/tui/tui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import type { EveTUIRunnerOptions } from "./runner.js";

const mocks = vi.hoisted<{ runnerOptions: EveTUIRunnerOptions[] }>(() => ({
runnerOptions: [],
}));

vi.mock("./runner.js", () => ({
EveTUIRunner: class {
constructor(options: EveTUIRunnerOptions) {
mocks.runnerOptions.push(options);
}

async run(): Promise<void> {}
},
}));

import { runDevelopmentTui, type DevelopmentTuiTarget } from "./tui.js";

describe("runDevelopmentTui", () => {
beforeEach(() => {
mocks.runnerOptions.length = 0;
});

it("creates a fresh client session for every TUI attached to the same server", async () => {
const target = {
kind: "local",
serverUrl: "http://127.0.0.1:4321/",
workspaceRoot: "/tmp/app",
} satisfies DevelopmentTuiTarget;
await runDevelopmentTui({ target });
await runDevelopmentTui({ target });

expect(mocks.runnerOptions).toHaveLength(2);
const [first, second] = mocks.runnerOptions;
if (first === undefined || second === undefined) {
throw new Error("Expected two TUI runner invocations.");
}
expect(first.client).not.toBe(second.client);
expect(first.session).not.toBe(second.session);
});
});
75 changes: 69 additions & 6 deletions packages/eve/src/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,19 @@ describe("eve dev boot progress", () => {
const close = vi.fn(async () => {});
let hostReporter: DevelopmentServerOptions["onBootProgress"] = undefined;
let tuiReporter: RunDevelopmentTuiInput["onBootProgress"] = undefined;
const startHost = vi.fn(async (_appRoot: string, options?: DevelopmentServerOptions) => {
hostReporter = options?.onBootProgress;
hostReporter?.({ phase: "compiling agent", type: "phase-started" });
hostReporter?.({ elapsedMs: 1, phase: "compiling agent", type: "phase-finished" });
return { close, url: "http://127.0.0.1:2000" };
});
const startHost = vi.fn((_appRoot: string, options?: DevelopmentServerOptions) => ({
start: async () => {
hostReporter = options?.onBootProgress;
hostReporter?.({ phase: "compiling agent", type: "phase-started" });
hostReporter?.({ elapsedMs: 1, phase: "compiling agent", type: "phase-finished" });
return {
kind: "started" as const,
appRoot: "/canonical/app",
url: "http://127.0.0.1:2000",
};
},
close,
}));
const runDevelopmentTui = vi.fn(async (input: RunDevelopmentTuiInput) => {
tuiReporter = input.onBootProgress;
throw new Error("TUI startup failed");
Expand All @@ -195,6 +202,62 @@ describe("eve dev boot progress", () => {
});
});

describe("eve dev local server ownership", () => {
it("uses the host's canonical root and leaves an attached server running", async () => {
const startHost = vi.fn(() => ({
start: async () => ({
kind: "existing" as const,
appRoot: "/canonical/app",
url: "http://127.0.0.1:4321/",
}),
close: async () => {},
}));
const runDevelopmentTui = vi.fn(async () => {});

await withInteractiveTerminal(() =>
runCli(["dev"], { error: () => {}, log: () => {} }, { runDevelopmentTui, startHost }),
);

expect(startHost).toHaveBeenCalledWith(expect.any(String), {
existing: "attach-if-unconfigured",
host: undefined,
onBootProgress: expect.any(Function),
port: undefined,
});
expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
name: "App",
target: {
kind: "local",
serverUrl: "http://127.0.0.1:4321/",
workspaceRoot: "/canonical/app",
},
}),
);
});

it("closes a server started for the interactive TUI", async () => {
const close = vi.fn(async () => {});
const startHost = vi.fn(() => ({
start: async () => ({
kind: "started" as const,
appRoot: "/canonical/app",
url: "http://127.0.0.1:4321/",
}),
close,
}));

await withInteractiveTerminal(() =>
runCli(
["dev"],
{ error: () => {}, log: () => {} },
{ runDevelopmentTui: vi.fn(async () => {}), startHost },
),
);
expect(close).toHaveBeenCalledOnce();
});
});

describe("resolveDevUiMode", () => {
it("defaults to the terminal UI in an interactive terminal", () => {
expect(resolveDevUiMode({ options: {}, interactive: true })).toBe("tui");
Expand Down
Loading
Loading