From c5f881e56391121719619a5148750b36be782aa2 Mon Sep 17 00:00:00 2001 From: Yash Khurana Date: Mon, 10 Aug 2026 18:00:29 -0700 Subject: [PATCH] refactor: move agent runtime into host connector --- .github/workflows/connector-release.yml | 1 + Dockerfile | 2 + README.md | 4 +- apps/connector/package.json | 5 +- apps/connector/src/cli.ts | 4 +- apps/connector/src/client.ts | 112 +- apps/connector/src/config.test.ts | 11 +- apps/connector/src/config.ts | 10 + apps/connector/src/daemon.test.ts | 200 +++ apps/connector/src/daemon.ts | 314 ++++ apps/connector/src/eventQueue.test.ts | 47 - apps/connector/src/eventQueue.ts | 17 - apps/connector/src/runtime.test.ts | 287 +--- apps/connector/src/runtime.ts | 195 +-- apps/connector/src/ssh.ts | 10 +- apps/connector/src/state.test.ts | 134 ++ apps/connector/src/state.ts | 330 ++++ apps/connector/tsconfig.json | 2 +- apps/site/public/_redirects | 4 +- apps/web/app/(app)/agents/[id]/page.tsx | 2 +- apps/web/app/(app)/layout.tsx | 5 +- .../connections/AddConnectionDialog.tsx | 4 +- .../connections/AddWorkspaceDialog.tsx | 2 +- .../settings/connections/ConnectionsPanel.tsx | 4 +- .../settings/connections/SshHostPicker.tsx | 2 +- .../app/(app)/settings/connections/page.tsx | 3 +- .../[id]/directories/route.test.ts | 29 +- .../[id]/directories/route.ts | 14 +- .../app/api/agent-connections/[id]/route.ts | 42 +- .../[id]/workspaces/route.ts | 64 +- .../agent-connections/discover/route.test.ts | 23 +- .../api/agent-connections/discover/route.ts | 23 +- .../app/api/agent-connections/probe/route.ts | 11 +- .../app/api/agent-connections/route.test.ts | 16 +- apps/web/app/api/agent-connections/route.ts | 17 +- .../agent-sessions/[id]/events/route.test.ts | 75 +- .../api/agent-sessions/[id]/events/route.ts | 95 +- .../app/api/agent-sessions/[id]/route.test.ts | 253 +-- apps/web/app/api/agent-sessions/[id]/route.ts | 101 +- .../[id]/git-status/route.test.ts | 32 +- .../agent-workspaces/[id]/git-status/route.ts | 14 +- .../app/api/agent-workspaces/[id]/route.ts | 41 +- .../agent-workspaces/[id]/sessions/route.ts | 32 +- .../api/host-connectors/channel/route.test.ts | 109 ++ .../app/api/host-connectors/channel/route.ts | 17 +- .../api/host-connectors/events/route.test.ts | 100 +- .../app/api/host-connectors/events/route.ts | 25 +- .../web/app/api/host-connectors/route.test.ts | 23 +- apps/web/app/api/host-connectors/route.ts | 3 +- .../api/host-connectors/uploads/[id]/route.ts | 27 + .../web/app/api/users/[id]/role/route.test.ts | 17 +- apps/web/app/api/users/[id]/role/route.ts | 9 +- apps/web/components/SidebarConnections.tsx | 4 +- apps/web/components/agents/AgentComposer.tsx | 6 +- .../agents/AgentComposerControls.tsx | 2 +- .../components/agents/AgentSessionDialogs.tsx | 2 +- .../components/agents/AgentSessionHeader.tsx | 2 +- .../components/agents/AgentSessionView.tsx | 8 +- apps/web/e2e/agent-runtime.spec.ts | 21 +- apps/web/e2e/connections.spec.ts | 2 +- apps/web/lib/agents/access.ts | 4 +- apps/web/lib/agents/connector/broker.test.ts | 355 ++-- apps/web/lib/agents/connector/broker.ts | 360 ++-- apps/web/lib/agents/connector/descriptors.ts | 92 + .../{runtime => connector}/status.test.ts | 15 +- .../agents/{runtime => connector}/status.ts | 10 +- apps/web/lib/agents/providers/registry.ts | 17 - apps/web/lib/agents/runtime/images.test.ts | 111 -- apps/web/lib/agents/runtime/images.ts | 48 - apps/web/lib/agents/runtime/process.test.ts | 60 - apps/web/lib/agents/runtime/registry.test.ts | 1496 ----------------- apps/web/lib/agents/runtime/target.ts | 24 - apps/web/lib/agents/sidebar.test.ts | 2 +- apps/web/lib/agents/sidebar.ts | 2 +- apps/web/lib/db/agentConnections.test.ts | 38 +- apps/web/lib/db/agentConnections.ts | 35 +- apps/web/lib/queries/agentConnections.ts | 2 +- apps/web/lib/queries/agentSessions.ts | 39 +- apps/web/lib/queries/agentWorkspaces.ts | 2 +- apps/web/package.json | 2 - package-lock.json | 46 +- packages/agent-bridge/package.json | 9 +- .../agent-bridge/src/agents.ts | 12 +- .../agent-bridge/src}/catalog.ts | 4 +- .../agent-bridge/src}/commands.test.ts | 2 +- .../agent-bridge/src}/commands.ts | 2 +- packages/agent-bridge/src/index.ts | 393 +++-- .../agent-bridge/src}/state.test.ts | 7 +- .../agent-bridge/src}/state.ts | 6 +- packages/agent-runtime/package.json | 27 + .../src}/codex/app-server.test.ts | 15 +- .../agent-runtime/src}/codex/app-server.ts | 7 +- .../agent-runtime/src}/codex/client.test.ts | 65 +- .../agent-runtime/src}/codex/client.ts | 39 +- .../agent-runtime/src}/codex/commands.test.ts | 3 +- .../agent-runtime/src}/codex/commands.ts | 7 +- .../agent-runtime/src}/codex/probe.test.ts | 9 +- .../agent-runtime/src}/codex/probe.ts | 11 +- .../agent-runtime/src}/codex/protocol.test.ts | 0 .../agent-runtime/src}/codex/protocol.ts | 2 +- .../agent-runtime/src}/codex/sessions.test.ts | 5 +- .../agent-runtime/src}/codex/sessions.ts | 9 +- packages/agent-runtime/src/index.ts | 10 + .../agent-runtime/src}/pi/client.test.ts | 3 +- .../agent-runtime/src}/pi/client.ts | 20 +- .../src}/pi/fixtures/overtchat-test-prompt.md | 0 .../pi/fixtures/overtchat-test-skill/SKILL.md | 0 .../src}/pi/fixtures/rpc-command.js | 0 .../src}/pi/omp.integration.test.ts | 5 +- .../src}/pi/probe.integration.test.ts | 5 +- .../agent-runtime/src}/pi/probe.test.ts | 15 +- .../agent-runtime/src}/pi/probe.ts | 11 +- .../agent-runtime/src}/pi/protocol.test.ts | 0 .../agent-runtime/src}/pi/protocol.ts | 4 +- .../agent-runtime/src}/pi/sessions.ts | 7 +- .../src}/providers/codex.test.ts | 1 - .../agent-runtime/src}/providers/codex.ts | 12 +- .../src}/providers/pi-rpc.test.ts | 8 +- .../agent-runtime/src}/providers/pi-rpc.ts | 20 +- .../agent-runtime/src/providers/registry.ts | 16 + .../agent-runtime/src}/providers/types.ts | 10 +- .../agent-runtime/src}/runtime/discovery.ts | 17 +- .../src}/runtime/filesystem.test.ts | 9 +- .../agent-runtime/src}/runtime/filesystem.ts | 3 +- .../agent-runtime/src}/runtime/git.test.ts | 11 +- .../agent-runtime/src}/runtime/git.ts | 5 +- .../agent-runtime/src}/runtime/jsonl.test.ts | 0 .../agent-runtime/src}/runtime/jsonl.ts | 0 .../src}/runtime/materialize-images.test.ts | 7 +- .../src}/runtime/materialize-images.ts | 7 +- .../agent-runtime/src}/runtime/process.ts | 101 +- .../src/runtime/registry.test.ts | 113 ++ .../agent-runtime/src}/runtime/registry.ts | 328 ++-- packages/agent-runtime/tsconfig.json | 15 + scripts/install-connector.sh | 2 +- 135 files changed, 3491 insertions(+), 3638 deletions(-) create mode 100644 apps/connector/src/daemon.test.ts create mode 100644 apps/connector/src/daemon.ts delete mode 100644 apps/connector/src/eventQueue.test.ts delete mode 100644 apps/connector/src/eventQueue.ts create mode 100644 apps/connector/src/state.test.ts create mode 100644 apps/connector/src/state.ts create mode 100644 apps/web/app/api/host-connectors/channel/route.test.ts create mode 100644 apps/web/app/api/host-connectors/uploads/[id]/route.ts create mode 100644 apps/web/lib/agents/connector/descriptors.ts rename apps/web/lib/agents/{runtime => connector}/status.test.ts (78%) rename apps/web/lib/agents/{runtime => connector}/status.ts (58%) delete mode 100644 apps/web/lib/agents/providers/registry.ts delete mode 100644 apps/web/lib/agents/runtime/images.test.ts delete mode 100644 apps/web/lib/agents/runtime/images.ts delete mode 100644 apps/web/lib/agents/runtime/process.test.ts delete mode 100644 apps/web/lib/agents/runtime/registry.test.ts delete mode 100644 apps/web/lib/agents/runtime/target.ts rename apps/web/lib/agents/types.ts => packages/agent-bridge/src/agents.ts (96%) rename {apps/web/lib/agents => packages/agent-bridge/src}/catalog.ts (92%) rename {apps/web/lib/agents/runtime => packages/agent-bridge/src}/commands.test.ts (99%) rename {apps/web/lib/agents/runtime => packages/agent-bridge/src}/commands.ts (99%) rename {apps/web/lib/agents/runtime => packages/agent-bridge/src}/state.test.ts (98%) rename {apps/web/lib/agents/runtime => packages/agent-bridge/src}/state.ts (98%) create mode 100644 packages/agent-runtime/package.json rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/app-server.test.ts (96%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/app-server.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/client.test.ts (96%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/client.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/commands.test.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/commands.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/probe.test.ts (91%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/probe.ts (89%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/protocol.test.ts (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/protocol.ts (99%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/sessions.test.ts (94%) rename {apps/web/lib/agents => packages/agent-runtime/src}/codex/sessions.ts (84%) create mode 100644 packages/agent-runtime/src/index.ts rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/client.test.ts (99%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/client.ts (97%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/fixtures/overtchat-test-prompt.md (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/fixtures/overtchat-test-skill/SKILL.md (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/fixtures/rpc-command.js (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/omp.integration.test.ts (97%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/probe.integration.test.ts (97%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/probe.test.ts (94%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/probe.ts (91%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/protocol.test.ts (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/protocol.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/pi/sessions.ts (96%) rename {apps/web/lib/agents => packages/agent-runtime/src}/providers/codex.test.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/providers/codex.ts (93%) rename {apps/web/lib/agents => packages/agent-runtime/src}/providers/pi-rpc.test.ts (96%) rename {apps/web/lib/agents => packages/agent-runtime/src}/providers/pi-rpc.ts (88%) create mode 100644 packages/agent-runtime/src/providers/registry.ts rename {apps/web/lib/agents => packages/agent-runtime/src}/providers/types.ts (94%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/discovery.ts (90%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/filesystem.test.ts (88%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/filesystem.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/git.test.ts (94%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/git.ts (98%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/jsonl.test.ts (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/jsonl.ts (100%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/materialize-images.test.ts (90%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/materialize-images.ts (87%) rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/process.ts (57%) create mode 100644 packages/agent-runtime/src/runtime/registry.test.ts rename {apps/web/lib/agents => packages/agent-runtime/src}/runtime/registry.ts (85%) create mode 100644 packages/agent-runtime/tsconfig.json diff --git a/.github/workflows/connector-release.yml b/.github/workflows/connector-release.yml index 0409edbb..cfed3d84 100644 --- a/.github/workflows/connector-release.yml +++ b/.github/workflows/connector-release.yml @@ -8,6 +8,7 @@ on: - ".github/workflows/connector-release.yml" - "apps/connector/**" - "packages/agent-bridge/**" + - "packages/agent-runtime/**" - "package.json" - "package-lock.json" - "scripts/install-connector.sh" diff --git a/Dockerfile b/Dockerfile index 424e0355..ee7afd01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ COPY apps/web/package.json ./apps/web/package.json +COPY packages/agent-bridge/package.json ./packages/agent-bridge/package.json +COPY packages/shared/package.json ./packages/shared/package.json RUN npm ci --include=dev FROM deps AS builder diff --git a/README.md b/README.md index ac159b6a..678853ec 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ Already run SearXNG or Kokoro elsewhere? You can point overtchat at them; see [d ## Agent Connections (Beta) -Use OvertChat as a browser interface for Pi and Oh My Pi installed on the Docker -host or on machines already reachable through its SSH config. In **Settings → +Use OvertChat as a browser interface for Codex, Pi, and Oh My Pi installed on +the Docker host or on machines already reachable through its SSH config. In **Settings → Connections**, choose **Set up** and run the generated command. It installs the OvertChat Host Connector as your Linux user; OvertChat never receives SSH keys or config. Remote aliases must already work non-interactively, diff --git a/apps/connector/package.json b/apps/connector/package.json index fe337225..7e0dc187 100644 --- a/apps/connector/package.json +++ b/apps/connector/package.json @@ -1,6 +1,6 @@ { "name": "@overtchat/connector", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "bin": { @@ -15,7 +15,8 @@ "test": "vitest run" }, "dependencies": { - "@overtchat/agent-bridge": "*" + "@overtchat/agent-bridge": "*", + "@overtchat/agent-runtime": "*" }, "devDependencies": { "@eslint/js": "^9", diff --git a/apps/connector/src/cli.ts b/apps/connector/src/cli.ts index 752f4e28..4d0c11f2 100644 --- a/apps/connector/src/cli.ts +++ b/apps/connector/src/cli.ts @@ -65,8 +65,8 @@ async function pair(values: Map): Promise { async function run(): Promise { const config = await readConnectorConfig(); - const client = new ConnectorClient(config); - const stop = () => client.stop(); + const client = await ConnectorClient.create(config); + const stop = () => void client.stop(); process.once("SIGINT", stop); process.once("SIGTERM", stop); console.log(`Connecting to ${config.serverUrl}`); diff --git a/apps/connector/src/client.ts b/apps/connector/src/client.ts index 9552e48e..bd211d37 100644 --- a/apps/connector/src/client.ts +++ b/apps/connector/src/client.ts @@ -1,15 +1,16 @@ import { HOST_CONNECTOR_PROTOCOL_VERSION, isHostConnectorCommand, - type HostConnectorEvent, + MAX_AGENT_IMAGE_BYTES, + type AgentPromptImage, + type HostConnectorEventAck, type HostConnectorEventBatch, + type HostConnectorEventPayload, } from "@overtchat/agent-bridge"; -import type { ConnectorConfig } from "./config.js"; -import { - restoreConnectorEventBatch, - takeConnectorEventBatch, -} from "./eventQueue.js"; -import { ConnectorRuntime } from "./runtime.js"; +import type { ResolvedAgentImage } from "@overtchat/agent-runtime"; +import { connectorStatePath, type ConnectorConfig } from "./config.js"; +import { ConnectorDaemon } from "./daemon.js"; +import { ConnectorStateJournal } from "./state.js"; import { CONNECTOR_VERSION } from "./version.js"; const RECONNECT_BASE_DELAY_MS = 1_000; @@ -42,8 +43,7 @@ function waitForRetry(milliseconds: number, signal: AbortSignal): Promise } export class ConnectorClient { - private readonly events: HostConnectorEvent[] = []; - private readonly runtime: ConnectorRuntime; + private readonly daemon: ConnectorDaemon; private readonly stopAbort = new AbortController(); private commandStreamAbort: AbortController | undefined; private eventRequestAbort: AbortController | undefined; @@ -53,11 +53,26 @@ export class ConnectorClient { private flushing = false; private stopped = false; - constructor(private readonly config: ConnectorConfig) { - this.runtime = new ConnectorRuntime((event) => this.enqueue(event)); + private constructor( + private readonly config: ConnectorConfig, + private readonly journal: ConnectorStateJournal, + ) { + this.daemon = new ConnectorDaemon( + (event) => this.enqueue(event), + (images) => this.resolveImages(images), + journal, + ); + } + + static async create(config: ConnectorConfig): Promise { + const journal = await ConnectorStateJournal.open( + connectorStatePath(config.connectorId), + ); + return new ConnectorClient(config, journal); } async run(): Promise { + void this.flush(); while (!this.stopped) { try { await this.openCommandStream(); @@ -76,16 +91,16 @@ export class ConnectorClient { } } - stop(): void { + async stop(): Promise { if (this.stopped) return; this.stopped = true; if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer = undefined; - this.events.length = 0; this.stopAbort.abort(); this.commandStreamAbort?.abort(); this.eventRequestAbort?.abort(); - this.runtime.stop(); + await this.daemon.stop(); + await this.journal.close(); } private async openCommandStream(): Promise { @@ -105,7 +120,12 @@ export class ConnectorClient { }, ); if (!response.ok || !response.body) { - throw new Error(`OvertChat returned HTTP ${response.status}.`); + const detail = (await response.json().catch(() => null)) as + | { error?: string } + | null; + throw new Error( + detail?.error ?? `OvertChat returned HTTP ${response.status}.`, + ); } this.reconnectAttempt = 0; const decoder = new TextDecoder(); @@ -125,7 +145,7 @@ export class ConnectorClient { if (!isHostConnectorCommand(command)) { throw new Error("OvertChat sent an invalid connector command."); } - await this.runtime.handle(command); + await this.daemon.handle(command); } newline = buffered.indexOf("\n"); } @@ -137,9 +157,9 @@ export class ConnectorClient { } } - private enqueue(event: HostConnectorEvent): void { + private enqueue(payload: HostConnectorEventPayload): void { if (this.stopped) return; - this.events.push(event); + this.journal.enqueue(payload); if (this.flushTimer || this.flushing) return; this.flushTimer = setTimeout(() => { this.flushTimer = undefined; @@ -148,11 +168,13 @@ export class ConnectorClient { } private async flush(): Promise { - if (this.stopped || this.flushing || this.events.length === 0) return; + if (this.stopped || this.flushing) return; + const events = this.journal.eventBatch(); + if (events.length === 0) return; this.flushing = true; - const events = takeConnectorEventBatch(this.events); const body: HostConnectorEventBatch = { protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION, + connectorEpoch: this.journal.connectorEpoch, events, }; const abort = new AbortController(); @@ -166,6 +188,10 @@ export class ConnectorClient { headers: { Authorization: `Bearer ${this.config.token}`, "Content-Type": "application/json", + "X-OvertChat-Connector-Version": CONNECTOR_VERSION, + "X-OvertChat-Connector-Protocol": String( + HOST_CONNECTOR_PROTOCOL_VERSION, + ), }, body: JSON.stringify(body), }, @@ -173,10 +199,17 @@ export class ConnectorClient { if (!response.ok) { throw new Error(`OvertChat returned HTTP ${response.status}.`); } + const ack = (await response.json()) as HostConnectorEventAck; + if ( + ack.connectorEpoch !== this.journal.connectorEpoch || + !Number.isSafeInteger(ack.acknowledgedSequence) + ) { + throw new Error("OvertChat returned an invalid connector acknowledgement."); + } + await this.journal.acknowledge(ack); this.eventRetryAttempt = 0; } catch (error) { if (this.stopped) return; - restoreConnectorEventBatch(this.events, events); console.error( `Unable to deliver connector events: ${ error instanceof Error ? error.message : String(error) @@ -191,7 +224,42 @@ export class ConnectorClient { this.eventRequestAbort = undefined; } this.flushing = false; - if (!this.stopped && this.events.length > 0) void this.flush(); + if (!this.stopped && this.journal.eventBatch().length > 0) { + void this.flush(); + } } } + + private async resolveImages( + images: readonly AgentPromptImage[], + ): Promise { + return Promise.all( + images.map(async (image) => { + const response = await fetch( + endpoint( + this.config.serverUrl, + `/api/host-connectors/uploads/${encodeURIComponent(image.uploadId)}`, + ), + { + headers: { + Authorization: `Bearer ${this.config.token}`, + }, + }, + ); + if (!response.ok) { + throw new Error( + `Unable to retrieve queued image ${image.filename} (HTTP ${response.status}).`, + ); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_AGENT_IMAGE_BYTES) { + throw new Error(`Agent image ${image.filename} is too large.`); + } + return { + ...image, + data: Buffer.from(bytes).toString("base64"), + }; + }), + ); + } } diff --git a/apps/connector/src/config.test.ts b/apps/connector/src/config.test.ts index e96384ab..b01e5111 100644 --- a/apps/connector/src/config.test.ts +++ b/apps/connector/src/config.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { normalizeServerUrl } from "./config.js"; +import { + connectorStatePath, + normalizeServerUrl, +} from "./config.js"; describe("connector server URLs", () => { it("accepts local HTTP and normalizes optional URL parts", () => { @@ -25,4 +28,10 @@ describe("connector server URLs", () => { "OvertChat URL must use HTTP or HTTPS.", ); }); + + it("keeps each paired connector's state separate", () => { + expect(connectorStatePath("connector-1")).toMatch( + /connector-connector-1\.state\.json$/u, + ); + }); }); diff --git a/apps/connector/src/config.ts b/apps/connector/src/config.ts index 5461b3fd..8d6c1f39 100644 --- a/apps/connector/src/config.ts +++ b/apps/connector/src/config.ts @@ -16,6 +16,16 @@ export function connectorConfigPath(): string { ); } +export function connectorStatePath(connectorId: string): string { + return ( + process.env.OVERTCHAT_CONNECTOR_STATE ?? + path.join( + path.dirname(connectorConfigPath()), + `connector-${connectorId}.state.json`, + ) + ); +} + export async function readConnectorConfig(): Promise { const file = connectorConfigPath(); let parsed: unknown; diff --git a/apps/connector/src/daemon.test.ts b/apps/connector/src/daemon.test.ts new file mode 100644 index 00000000..c54c2212 --- /dev/null +++ b/apps/connector/src/daemon.test.ts @@ -0,0 +1,200 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + AgentDaemonSessionDescriptor, + HostConnectorCommand, + HostConnectorEventPayload, +} from "@overtchat/agent-bridge"; + +const mocks = vi.hoisted(() => ({ + configureProcessSpawner: vi.fn(), + getOrStart: vi.fn(), + stopAll: vi.fn(), + stopSession: vi.fn(), + command: vi.fn(), + normalizeCommand: vi.fn(), + snapshot: vi.fn(), +})); + +vi.mock("@overtchat/agent-runtime", async (importOriginal) => { + const original = await importOriginal< + typeof import("@overtchat/agent-runtime") + >(); + return { + ...original, + configureProcessSpawner: mocks.configureProcessSpawner, + AgentRuntimeRegistry: class { + getOrStart = mocks.getOrStart; + stopAll = mocks.stopAll; + stopSession = mocks.stopSession; + }, + }; +}); + +import { ConnectorDaemon } from "./daemon.js"; +import { ConnectorStateJournal } from "./state.js"; + +const directories: string[] = []; + +const session: AgentDaemonSessionDescriptor = { + connectionId: "connection", + workspaceId: "workspace", + provider: "codex", + target: { transport: "local", shellMode: "interactive" }, + executable: "codex", + cwd: "/workspace", + sessionId: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", +}; + +function command(requestId: string): HostConnectorCommand { + return { + type: "request", + requestId, + request: { + type: "session_command", + commandId: "message-1", + clientMessageId: "message-1", + session, + command: { + type: "queue", + message: "Run the tests", + clientMessageId: "message-1", + }, + }, + }; +} + +async function openJournal(): Promise<{ + file: string; + journal: ConnectorStateJournal; +}> { + const directory = await mkdtemp(path.join(os.tmpdir(), "overtchat-daemon-")); + directories.push(directory); + const file = path.join(directory, "connector.state.json"); + return { file, journal: await ConnectorStateJournal.open(file) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.normalizeCommand.mockImplementation((value) => value); + mocks.command.mockResolvedValue({ queued: true, id: "message-1" }); + mocks.snapshot.mockReturnValue({ + sessionId: "session", + status: "running", + queuedMessages: [ + { id: "message-1", message: "Run the tests", status: "pending" }, + ], + }); + mocks.getOrStart.mockResolvedValue({ + normalizeCommand: mocks.normalizeCommand, + command: mocks.command, + snapshot: mocks.snapshot, + }); + mocks.stopAll.mockResolvedValue(undefined); + mocks.stopSession.mockResolvedValue(undefined); +}); + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ); +}); + +describe("connector daemon command identity", () => { + it("executes simultaneous deliveries of one command only once", async () => { + const { journal } = await openJournal(); + const events: HostConnectorEventPayload[] = []; + const daemon = new ConnectorDaemon( + (event) => events.push(event), + async () => [], + journal, + ); + + await Promise.all([ + daemon.handle(command("request-1")), + daemon.handle(command("request-2")), + ]); + + expect(mocks.command).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + expect.objectContaining({ + type: "response", + requestId: "request-1", + success: true, + }), + expect.objectContaining({ + type: "response", + requestId: "request-2", + success: true, + }), + ]); + await journal.close(); + }); + + it("reuses an accepted result after a daemon restart", async () => { + const { file, journal } = await openJournal(); + const firstEvents: HostConnectorEventPayload[] = []; + const first = new ConnectorDaemon( + (event) => firstEvents.push(event), + async () => [], + journal, + ); + await first.handle(command("request-1")); + await journal.close(); + + mocks.getOrStart.mockClear(); + mocks.command.mockClear(); + const restored = await ConnectorStateJournal.open(file); + const secondEvents: HostConnectorEventPayload[] = []; + const second = new ConnectorDaemon( + (event) => secondEvents.push(event), + async () => [], + restored, + ); + await second.handle(command("request-2")); + + expect(mocks.getOrStart).not.toHaveBeenCalled(); + expect(mocks.command).not.toHaveBeenCalled(); + expect(secondEvents).toEqual([ + expect.objectContaining({ + type: "response", + requestId: "request-2", + success: true, + data: expect.objectContaining({ + snapshot: expect.objectContaining({ + queuedMessages: [ + expect.objectContaining({ id: "message-1" }), + ], + }), + }), + }), + ]); + await restored.close(); + }); + + it("stops journaled sessions that the server no longer authorizes", async () => { + const { journal } = await openJournal(); + await journal.recordSession(session); + const daemon = new ConnectorDaemon( + vi.fn(), + async () => [], + journal, + ); + + await daemon.handle({ + type: "sync", + connectionEpoch: "connection-1", + activeSessionIds: [], + }); + + expect(mocks.stopSession).toHaveBeenCalledWith("session"); + expect(journal.sessionIds()).toEqual([]); + await journal.close(); + }); +}); diff --git a/apps/connector/src/daemon.ts b/apps/connector/src/daemon.ts new file mode 100644 index 00000000..c6569a0e --- /dev/null +++ b/apps/connector/src/daemon.ts @@ -0,0 +1,314 @@ +import type { + AgentDaemonRequest, + AgentDaemonSessionDescriptor, + AgentDaemonTarget, + AgentDaemonWorkspaceDescriptor, + AgentPromptImage, + HostConnectorCommand, + HostConnectorEventPayload, +} from "@overtchat/agent-bridge"; +import { + AgentRuntimeRegistry, + agentProviderAdapter, + configureProcessSpawner, + discoverAgentInstallations, + inspectAgentWorkspaceGitStatus, + listAgentDirectories, + probeAgentWorkspace, + targetForDiscovery, + type AgentSessionRuntime, + type HostTarget, + type ResolvedAgentImage, +} from "@overtchat/agent-runtime"; +import { listSshHosts } from "./ssh.js"; +import { ConnectorProcessHost } from "./runtime.js"; +import { ConnectorStateJournal } from "./state.js"; + +type Emit = (event: HostConnectorEventPayload) => void; +type ResolveImages = ( + images: readonly AgentPromptImage[], +) => Promise; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hostTarget(target: AgentDaemonTarget): HostTarget { + return target.transport === "local" + ? { transport: "local", shellMode: target.shellMode } + : { + transport: "ssh", + alias: target.alias, + shellMode: target.shellMode, + }; +} + +function workspaceDescriptor( + descriptor: AgentDaemonWorkspaceDescriptor, +) { + return { + ...descriptor, + target: hostTarget(descriptor.target), + }; +} + +function sessionDescriptor(descriptor: AgentDaemonSessionDescriptor) { + return { + ...descriptor, + target: hostTarget(descriptor.target), + }; +} + +export class ConnectorDaemon { + private readonly processHost = new ConnectorProcessHost(); + private readonly registry: AgentRuntimeRegistry; + private readonly subscriptions = new Map void>(); + private readonly sessionTails = new Map>(); + private connectionEpoch: string | null = null; + + constructor( + private readonly emit: Emit, + resolveImages: ResolveImages, + private readonly journal: ConnectorStateJournal, + ) { + configureProcessSpawner(this.processHost.spawn); + this.registry = new AgentRuntimeRegistry({ + resolveImages, + updateSessionMetadata: (sessionId, patch) => { + const { providerModifiedAt, ...metadata } = patch; + this.emit({ + type: "session_metadata", + sessionId, + patch: { + ...metadata, + ...(providerModifiedAt + ? { providerModifiedAt: providerModifiedAt.getTime() } + : {}), + }, + }); + }, + loadQueuedMessages: (sessionId) => this.journal.sessionQueue(sessionId), + saveQueuedMessages: (sessionId, messages) => + this.journal.saveSessionQueue(sessionId, messages), + }); + } + + async handle(command: HostConnectorCommand): Promise { + if (command.type === "sync") { + await this.beginConnection( + command.connectionEpoch, + command.activeSessionIds, + ); + return; + } + try { + const data = await this.handleRequest(command.request); + this.emit({ + type: "response", + requestId: command.requestId, + success: true, + data, + }); + } catch (error) { + this.emit({ + type: "response", + requestId: command.requestId, + success: false, + error: errorMessage(error), + }); + } + } + + async stop(): Promise { + for (const unsubscribe of this.subscriptions.values()) unsubscribe(); + this.subscriptions.clear(); + await this.registry.stopAll(); + this.processHost.stop(); + } + + private async beginConnection( + connectionEpoch: string, + activeSessionIds: string[], + ): Promise { + if (this.connectionEpoch !== connectionEpoch) { + this.connectionEpoch = connectionEpoch; + for (const unsubscribe of this.subscriptions.values()) unsubscribe(); + this.subscriptions.clear(); + } + const active = new Set(activeSessionIds); + await Promise.all( + this.journal + .sessionIds() + .filter((sessionId) => !active.has(sessionId)) + .map((sessionId) => this.registry.stopSession(sessionId)), + ); + await this.journal.retainSessions(active); + } + + private async handleRequest(request: AgentDaemonRequest): Promise { + switch (request.type) { + case "list_ssh_hosts": + return listSshHosts(); + case "discover": + return discoverAgentInstallations(targetForDiscovery(request.target)); + case "probe": + return agentProviderAdapter(request.draft.provider).probeConnection( + request.draft, + ); + case "list_sessions": { + const workspace = workspaceDescriptor(request.workspace); + return agentProviderAdapter(workspace.provider).listWorkspaceSessions( + workspace.target, + workspace.executable, + workspace.cwd, + ); + } + case "list_directories": + return listAgentDirectories(hostTarget(request.target), request.path); + case "probe_workspace": + return probeAgentWorkspace(hostTarget(request.target), request.path); + case "git_status": + return inspectAgentWorkspaceGitStatus( + hostTarget(request.target), + request.path, + ); + case "create_session": { + const created = await this.serializeSession(request.sessionId, () => + this.registry.create( + request.sessionId, + workspaceDescriptor(request.workspace), + ), + ); + await this.journal.recordSession({ + ...request.workspace, + sessionId: request.sessionId, + providerSessionId: created.session.providerSessionId, + providerSessionPath: created.session.providerSessionPath, + }); + return { + session: created.session, + snapshot: created.runtime.snapshot(), + }; + } + case "open_session": { + const runtime = await this.open(request.session); + return { snapshot: runtime.snapshot() }; + } + case "session_command": + return this.runCommand(request); + case "subscribe_session": { + this.subscriptions.get(request.subscriptionId)?.(); + const runtime = await this.open(request.session); + const unsubscribe = runtime.subscribe((envelope) => { + this.emit({ + type: "session_event", + subscriptionId: request.subscriptionId, + sessionId: request.session.sessionId, + envelope, + }); + }, request.after); + this.subscriptions.set(request.subscriptionId, unsubscribe); + return { subscribed: true }; + } + case "unsubscribe_session": + this.subscriptions.get(request.subscriptionId)?.(); + this.subscriptions.delete(request.subscriptionId); + return { subscribed: false }; + case "stop_session": + await this.registry.stopSession(request.sessionId); + await this.journal.deleteSession(request.sessionId); + return { stopped: true }; + case "stop_workspace": + await this.registry.stopWorkspace(request.workspaceId); + await this.journal.deleteWorkspace(request.workspaceId); + return { stopped: true }; + case "stop_connection": + await this.registry.stopConnection(request.connectionId); + await this.journal.deleteConnection(request.connectionId); + return { stopped: true }; + case "stop_all": + await this.registry.stopAll(); + await this.journal.deleteAllSessions(); + return { stopped: true }; + } + } + + private async open( + descriptor: AgentDaemonSessionDescriptor, + ): Promise { + return this.serializeSession(descriptor.sessionId, async () => { + const runtime = await this.registry.getOrStart( + sessionDescriptor(descriptor), + ); + await this.journal.recordSession(descriptor); + return runtime; + }); + } + + private async runCommand( + request: Extract, + ): Promise { + const cached = this.journal.commandResult(request.commandId); + if (cached) { + if (cached.success) return cached.data; + throw new Error(cached.error); + } + return this.serializeSession(request.session.sessionId, async () => { + const insideCached = this.journal.commandResult(request.commandId); + if (insideCached) { + if (insideCached.success) return insideCached.data; + throw new Error(insideCached.error); + } + try { + await this.journal.recordSession(request.session); + const runtime = await this.registry.getOrStart( + sessionDescriptor(request.session), + ); + const normalized = runtime.normalizeCommand(request.command); + const data = + normalized.type === "edit_message" || + normalized.type === "fork_message" + ? { + fork: await this.registry.fork(runtime, normalized), + } + : { + commandResult: await runtime.command( + normalized, + request.clientMessageId, + ), + snapshot: { + queuedMessages: runtime.snapshot().queuedMessages, + }, + }; + await this.journal.recordCommandResult(request.commandId, { + success: true, + data: data ?? null, + }); + return data; + } catch (error) { + const result = { success: false, error: errorMessage(error) } as const; + await this.journal.recordCommandResult(request.commandId, result); + throw error; + } + }); + } + + private serializeSession( + sessionId: string, + operation: () => Promise, + ): Promise { + const previous = this.sessionTails.get(sessionId) ?? Promise.resolve(); + const result = previous.catch(() => {}).then(operation); + const tail = result.then( + () => {}, + () => {}, + ); + this.sessionTails.set(sessionId, tail); + void tail.finally(() => { + if (this.sessionTails.get(sessionId) === tail) { + this.sessionTails.delete(sessionId); + } + }); + return result; + } +} diff --git a/apps/connector/src/eventQueue.test.ts b/apps/connector/src/eventQueue.test.ts deleted file mode 100644 index 8419b56d..00000000 --- a/apps/connector/src/eventQueue.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { HOST_CONNECTOR_EVENT_BATCH_LIMIT } from "@overtchat/agent-bridge"; -import { describe, expect, it } from "vitest"; -import type { HostConnectorEvent } from "@overtchat/agent-bridge"; -import { - restoreConnectorEventBatch, - takeConnectorEventBatch, -} from "./eventQueue.js"; - -function event(index: number): HostConnectorEvent { - return { - type: "stdout", - processId: "process", - data: String(index), - }; -} - -describe("connector event queue", () => { - it("splits backlogs into server-sized batches", () => { - const queue = Array.from( - { length: HOST_CONNECTOR_EVENT_BATCH_LIMIT + 1 }, - (_, index) => event(index), - ); - - const first = takeConnectorEventBatch(queue); - const second = takeConnectorEventBatch(queue); - - expect(first).toHaveLength(HOST_CONNECTOR_EVENT_BATCH_LIMIT); - expect(second).toEqual([event(HOST_CONNECTOR_EVENT_BATCH_LIMIT)]); - expect(queue).toEqual([]); - }); - - it("restores a failed batch ahead of events that arrived while sending", () => { - const queue = [event(0), event(1), event(2)]; - const batch = takeConnectorEventBatch(queue); - queue.push(event(3), event(4)); - - restoreConnectorEventBatch(queue, batch); - - expect(queue).toEqual([ - event(0), - event(1), - event(2), - event(3), - event(4), - ]); - }); -}); diff --git a/apps/connector/src/eventQueue.ts b/apps/connector/src/eventQueue.ts deleted file mode 100644 index 95ad58b3..00000000 --- a/apps/connector/src/eventQueue.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - HOST_CONNECTOR_EVENT_BATCH_LIMIT, - type HostConnectorEvent, -} from "@overtchat/agent-bridge"; - -export function takeConnectorEventBatch( - queue: HostConnectorEvent[], -): HostConnectorEvent[] { - return queue.splice(0, HOST_CONNECTOR_EVENT_BATCH_LIMIT); -} - -export function restoreConnectorEventBatch( - queue: HostConnectorEvent[], - batch: HostConnectorEvent[], -): void { - queue.unshift(...batch); -} diff --git a/apps/connector/src/runtime.test.ts b/apps/connector/src/runtime.test.ts index 4fb4721a..76b7cec8 100644 --- a/apps/connector/src/runtime.test.ts +++ b/apps/connector/src/runtime.test.ts @@ -1,269 +1,82 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { - HOST_CONNECTOR_PROTOCOL_MIN_VERSION, HOST_CONNECTOR_PROTOCOL_VERSION, isHostConnectorCommand, isHostConnectorProtocolVersion, - type HostConnectorEvent, } from "@overtchat/agent-bridge"; -import { ConnectorRuntime } from "./runtime.js"; - -async function waitForExit( - events: HostConnectorEvent[], - processId: string, -): Promise> { - await vi.waitFor(() => { - expect( - events.some( - (event) => event.type === "exit" && event.processId === processId, - ), - ).toBe(true); +import { ConnectorProcessHost } from "./runtime.js"; + +async function outputOf( + host: ConnectorProcessHost, + command: string, + input = "", +): Promise<{ stdout: string; code: number | null }> { + const child = host.spawn( + { transport: "local", shellMode: "login" }, + { + command, + args: command === process.execPath + ? ["-e", "process.stdin.pipe(process.stdout)"] + : ["%s", input], + shellMode: "login", + }, + ); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); }); - return events.find( - (event): event is Extract => - event.type === "exit" && event.processId === processId, - )!; + if (command === process.execPath) child.stdin.end(input); + else child.stdin.end(); + const exit = await child.exit; + return { stdout, code: exit.code }; } -describe("connector process runtime", () => { - it("bridges stdin, stdout, and process exit for local commands", async () => { - const events: HostConnectorEvent[] = []; - const runtime = new ConnectorRuntime((event) => events.push(event)); - const processId = "local-echo"; - - await runtime.handle({ - type: "spawn", - processId, - target: { transport: "local" }, - launch: { - command: process.execPath, - args: ["-e", "process.stdin.pipe(process.stdout)"], - shellMode: "login", - }, - }); - await runtime.handle({ - type: "stdin", - processId, - data: Buffer.from("hello connector").toString("base64"), - }); - await runtime.handle({ type: "stdin_end", processId }); - - expect(await waitForExit(events, processId)).toMatchObject({ code: 0 }); - const stdout = events - .map((event) => - event.type === "stdout" && event.processId === processId - ? Buffer.from(event.data, "base64").toString() - : "", - ) - .join(""); - expect(stdout).toBe("hello connector"); - runtime.stop(); - }); - - it("loads the local login environment without leaking startup stdout", async () => { - const events: HostConnectorEvent[] = []; - const runtime = new ConnectorRuntime((event) => events.push(event)); - const processId = "local-login"; - - await runtime.handle({ - type: "spawn", - processId, - target: { transport: "local" }, - launch: { - command: "printf", - args: ["%s", "login path works"], - shellMode: "login", - }, - }); - - expect(await waitForExit(events, processId)).toMatchObject({ code: 0 }); - expect( - events - .map((event) => - event.type === "stdout" && event.processId === processId - ? Buffer.from(event.data, "base64").toString() - : "", - ) - .join(""), - ).toBe("login path works"); - expect( - events - .map((event) => - event.type === "stderr" && event.processId === processId - ? Buffer.from(event.data, "base64").toString() - : "", - ) - .join(""), - ).not.toMatch(/job control|terminal process group/iu); - }); - - it("loads interactive-only PATH entries for agent launches", async () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "overtchat-connector-shell-"), - ); - const originalHome = process.env.HOME; - const originalShell = process.env.SHELL; - try { - const tools = path.join(home, "tools"); - fs.mkdirSync(tools); - fs.writeFileSync( - path.join(home, ".bash_profile"), - '. "$HOME/.bashrc"\n', - ); - fs.writeFileSync( - path.join(home, ".bashrc"), - [ - 'case "$-" in', - " *i*) ;;", - " *) return ;;", - "esac", - 'export PATH="$HOME/tools:$PATH"', - ].join("\n"), - ); - const executable = path.join(tools, "overtchat-test-agent"); - fs.writeFileSync(executable, "#!/bin/sh\nprintf 'interactive path works'\n"); - fs.chmodSync(executable, 0o755); - process.env.HOME = home; - process.env.SHELL = "/bin/bash"; - - const events: HostConnectorEvent[] = []; - const runtime = new ConnectorRuntime((event) => events.push(event)); - const processId = "interactive-login"; - await runtime.handle({ - type: "spawn", - processId, - target: { transport: "local" }, - launch: { - command: "overtchat-test-agent", - shellMode: "interactive", - }, - }); - - expect(await waitForExit(events, processId)).toMatchObject({ code: 0 }); - expect( - events - .map((event) => - event.type === "stdout" && event.processId === processId - ? Buffer.from(event.data, "base64").toString() - : "", - ) - .join(""), - ).toBe("interactive path works"); - runtime.stop(); - } finally { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalShell === undefined) delete process.env.SHELL; - else process.env.SHELL = originalShell; - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it("fails stale server process IDs after a connector restart", async () => { - const events: HostConnectorEvent[] = []; - const runtime = new ConnectorRuntime((event) => events.push(event)); - - await runtime.handle({ - type: "sync", - processIds: ["process-from-old-connector"], +describe("connector process host", () => { + it("owns local process stdin, stdout, and exit", async () => { + const host = new ConnectorProcessHost(); + await expect(outputOf(host, process.execPath, "hello daemon")).resolves.toEqual({ + stdout: "hello daemon", + code: 0, }); - - expect(events).toEqual([ - { - type: "exit", - processId: "process-from-old-connector", - code: null, - signal: null, - error: "The Host Connector restarted while the agent was running.", - }, - ]); + host.stop(); }); - it("reports invalid SSH aliases as process failures", async () => { - const events: HostConnectorEvent[] = []; - const runtime = new ConnectorRuntime((event) => events.push(event)); - - await runtime.handle({ - type: "spawn", - processId: "invalid-ssh", - target: { transport: "ssh", alias: "user@host" }, - launch: { command: "omp", shellMode: "interactive" }, + it("loads the configured shell environment without startup noise", async () => { + const host = new ConnectorProcessHost(); + await expect(outputOf(host, "printf", "login works")).resolves.toEqual({ + stdout: "login works", + code: 0, }); - - expect(events).toEqual([ - expect.objectContaining({ - type: "exit", - processId: "invalid-ssh", - error: "Invalid SSH host alias.", - }), - ]); + host.stop(); }); }); describe("connector protocol validation", () => { - it("accepts supported commands and protocol versions", () => { + it("uses one exact protocol version and agent-level requests", () => { + expect(isHostConnectorProtocolVersion(HOST_CONNECTOR_PROTOCOL_VERSION)).toBe(true); + expect(isHostConnectorProtocolVersion(HOST_CONNECTOR_PROTOCOL_VERSION + 1)).toBe(false); expect( isHostConnectorCommand({ - type: "spawn", - processId: "process", - target: { transport: "ssh", alias: "devbox" }, - launch: { - command: "omp", - args: ["--mode", "rpc"], - env: { OVERTCHAT: "1" }, - shellMode: "interactive", - }, + type: "sync", + connectionEpoch: "web-process-1", + activeSessionIds: [], }), ).toBe(true); expect( - isHostConnectorProtocolVersion(HOST_CONNECTOR_PROTOCOL_MIN_VERSION), - ).toBe(true); - expect( - isHostConnectorProtocolVersion(HOST_CONNECTOR_PROTOCOL_VERSION), + isHostConnectorCommand({ + type: "request", + requestId: "request-1", + request: { type: "list_ssh_hosts" }, + }), ).toBe(true); }); - it("rejects malformed commands and unsupported protocols", () => { + it("rejects commands outside the agent daemon protocol", () => { expect( isHostConnectorCommand({ type: "spawn", processId: "process", - target: { transport: "ssh" }, - launch: { command: "omp" }, }), ).toBe(false); - expect( - isHostConnectorCommand({ - type: "kill", - processId: "process", - signal: "NOT_A_SIGNAL", - }), - ).toBe(false); - expect( - isHostConnectorCommand({ - type: "spawn", - processId: "process", - target: { transport: "local" }, - launch: { - command: "omp", - env: { "INVALID-NAME": "value" }, - shellMode: "interactive", - }, - }), - ).toBe(false); - expect( - isHostConnectorCommand({ - type: "spawn", - processId: "process", - target: { transport: "local" }, - launch: { command: "omp" }, - }), - ).toBe(false); - expect( - isHostConnectorProtocolVersion(HOST_CONNECTOR_PROTOCOL_VERSION + 1), - ).toBe(false); }); }); diff --git a/apps/connector/src/runtime.ts b/apps/connector/src/runtime.ts index bdc9d725..7165bf34 100644 --- a/apps/connector/src/runtime.ts +++ b/apps/connector/src/runtime.ts @@ -1,17 +1,13 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import type { - ConnectorProcessLaunch, ConnectorTarget, - HostConnectorCommand, - HostConnectorEvent, } from "@overtchat/agent-bridge"; -import { - buildSshRemoteCommand, - listSshHosts, - sshSpawnArgs, -} from "./ssh.js"; - -type Emit = (event: HostConnectorEvent) => void; +import type { + AgentProcess, + AgentProcessHostLaunch, + HostTarget, +} from "@overtchat/agent-runtime"; +import { buildSshRemoteCommand, sshSpawnArgs } from "./ssh.js"; function killProcessTree( child: ChildProcessWithoutNullStreams, @@ -29,118 +25,40 @@ function killProcessTree( return child.kill(signal); } -function terminateProcessTree(child: ChildProcessWithoutNullStreams): void { - if (!killProcessTree(child, "SIGTERM")) return; - const forceKillTimer = setTimeout(() => { - killProcessTree(child, "SIGKILL"); - }, 1_000); - child.once("exit", () => clearTimeout(forceKillTimer)); +function targetForSpawn(target: HostTarget): ConnectorTarget { + return target.transport === "local" + ? { transport: "local" } + : { transport: "ssh", alias: target.alias }; } -export class ConnectorRuntime { - private readonly processes = new Map< - string, - ChildProcessWithoutNullStreams - >(); - - constructor(private readonly emit: Emit) {} - - async handle(command: HostConnectorCommand): Promise { - switch (command.type) { - case "sync": - this.sync(command.processIds); - return; - case "spawn": - this.spawn(command.processId, command.target, command.launch); - return; - case "stdin": - this.processes - .get(command.processId) - ?.stdin.write(Buffer.from(command.data, "base64")); - return; - case "stdin_end": - this.processes.get(command.processId)?.stdin.end(); - return; - case "kill": - { - const child = this.processes.get(command.processId); - if (child) killProcessTree(child, command.signal); - } - return; - case "request": - await this.request(command.requestId, command.request); - } - } - - stop(): void { - for (const child of this.processes.values()) { - terminateProcessTree(child); - } - } +export class ConnectorProcessHost { + private readonly processes = new Set(); - private sync(activeProcessIds: string[]): void { - const active = new Set(activeProcessIds); - for (const [id, child] of this.processes) { - if (!active.has(id)) terminateProcessTree(child); - } - for (const id of active) { - if (!this.processes.has(id)) { - this.emit({ - type: "exit", - processId: id, - code: null, - signal: null, - error: "The Host Connector restarted while the agent was running.", - }); - } - } - } + spawn = ( + target: HostTarget, + launch: AgentProcessHostLaunch, + ): AgentProcess => { + const connectorTarget = targetForSpawn(target); + const child = + connectorTarget.transport === "local" + ? spawn("/bin/sh", ["-c", buildSshRemoteCommand(launch)], { + env: process.env, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + }) + : spawn("ssh", sshSpawnArgs(connectorTarget.alias, launch), { + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + this.processes.add(child); + child.once("exit", () => this.processes.delete(child)); + child.once("error", () => this.processes.delete(child)); - private spawn( - processId: string, - target: ConnectorTarget, - launch: ConnectorProcessLaunch, - ): void { - if (this.processes.has(processId)) return; - let child: ChildProcessWithoutNullStreams; - try { - child = - target.transport === "local" - ? spawn("/bin/sh", ["-c", buildSshRemoteCommand(launch)], { - env: process.env, - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - }) - : spawn("ssh", sshSpawnArgs(target.alias, launch), { - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - }); - } catch (error) { - this.emit({ - type: "exit", - processId, - code: null, - signal: null, - error: error instanceof Error ? error.message : String(error), - }); - return; - } - this.processes.set(processId, child); - child.stdout.on("data", (chunk: Buffer) => - this.emit({ - type: "stdout", - processId, - data: chunk.toString("base64"), - }), - ); - child.stderr.on("data", (chunk: Buffer) => - this.emit({ - type: "stderr", - processId, - data: chunk.toString("base64"), - }), - ); let settled = false; + let settle: (value: Awaited) => void = () => {}; + const exit = new Promise>((resolve) => { + settle = resolve; + }); const finish = ( code: number | null, signal: NodeJS.Signals | null, @@ -148,37 +66,26 @@ export class ConnectorRuntime { ) => { if (settled) return; settled = true; - this.processes.delete(processId); - this.emit({ - type: "exit", - processId, - code, - signal, - ...(error ? { error: error.message } : {}), - }); + settle({ code, signal, ...(error ? { error } : {}) }); }; child.once("error", (error) => finish(null, null, error)); child.once("exit", (code, signal) => finish(code, signal)); - } - private async request( - requestId: string, - request: Extract< - HostConnectorCommand, - { type: "request" } - >["request"], - ): Promise { - try { - const data = - request.type === "list_ssh_hosts" ? await listSshHosts() : null; - this.emit({ type: "response", requestId, success: true, data }); - } catch (error) { - this.emit({ - type: "response", - requestId, - success: false, - error: error instanceof Error ? error.message : String(error), - }); + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exit, + kill: (signal = "SIGTERM") => killProcessTree(child, signal), + }; + }; + + stop(): void { + for (const child of this.processes) { + if (!killProcessTree(child, "SIGTERM")) continue; + const timer = setTimeout(() => killProcessTree(child, "SIGKILL"), 1_000); + timer.unref(); + child.once("exit", () => clearTimeout(timer)); } } } diff --git a/apps/connector/src/ssh.ts b/apps/connector/src/ssh.ts index 803de1d2..52f731a8 100644 --- a/apps/connector/src/ssh.ts +++ b/apps/connector/src/ssh.ts @@ -3,10 +3,8 @@ import { readFile, readdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import type { - ConnectorProcessLaunch, - ConnectorSshHost, -} from "@overtchat/agent-bridge"; +import type { ConnectorSshHost } from "@overtchat/agent-bridge"; +import type { AgentProcessHostLaunch } from "@overtchat/agent-runtime"; const execFileAsync = promisify(execFile); const MAX_ALIASES = 128; @@ -18,7 +16,7 @@ function shellQuote(value: string): string { } export function buildSshRemoteCommand( - launch: ConnectorProcessLaunch, + launch: AgentProcessHostLaunch, ): string { const env = Object.entries(launch.env ?? {}) .map(([key, value]) => `${key}=${shellQuote(value)}`) @@ -38,7 +36,7 @@ export function buildSshRemoteCommand( export function sshSpawnArgs( alias: string, - launch: ConnectorProcessLaunch, + launch: AgentProcessHostLaunch, ): string[] { if (!SAFE_ALIAS.test(alias)) throw new Error("Invalid SSH host alias."); return [ diff --git a/apps/connector/src/state.test.ts b/apps/connector/src/state.test.ts new file mode 100644 index 00000000..201496e1 --- /dev/null +++ b/apps/connector/src/state.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentDaemonSessionDescriptor } from "@overtchat/agent-bridge"; +import { ConnectorStateJournal } from "./state.js"; + +const directories: string[] = []; + +async function journal(): Promise<{ + file: string; + value: ConnectorStateJournal; +}> { + const directory = await mkdtemp(path.join(os.tmpdir(), "overtchat-state-")); + directories.push(directory); + const file = path.join(directory, "connector.state.json"); + return { file, value: await ConnectorStateJournal.open(file) }; +} + +const session: AgentDaemonSessionDescriptor = { + connectionId: "connection", + workspaceId: "workspace", + provider: "codex", + target: { transport: "local", shellMode: "interactive" }, + executable: "codex", + cwd: "/workspace", + sessionId: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", +}; + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ); +}); + +describe("connector state journal", () => { + it("restores unacknowledged events with the same transport identity", async () => { + const { file, value } = await journal(); + const epoch = value.connectorEpoch; + value.enqueue({ + type: "response", + requestId: "request-1", + success: true, + data: { accepted: true }, + }); + await value.close(); + + const restored = await ConnectorStateJournal.open(file); + expect(restored.connectorEpoch).toBe(epoch); + expect(restored.eventBatch()).toEqual([ + { + sequence: 1, + payload: { + type: "response", + requestId: "request-1", + success: true, + data: { accepted: true }, + }, + }, + ]); + + await restored.acknowledge({ + connectorEpoch: epoch, + acknowledgedSequence: 1, + }); + await restored.close(); + const acknowledged = await ConnectorStateJournal.open(file); + expect(acknowledged.eventBatch()).toEqual([]); + await acknowledged.close(); + }); + + it("persists accepted command results and pending session queues", async () => { + const { file, value } = await journal(); + await value.recordSession(session); + await value.saveSessionQueue("session", [ + { + id: "message-1", + message: "Run the tests", + status: "sending", + }, + ]); + await value.recordCommandResult("message-1", { + success: true, + data: { queued: true, id: "message-1" }, + }); + await value.close(); + + const restored = await ConnectorStateJournal.open(file); + expect(restored.commandResult("message-1")).toEqual({ + success: true, + data: { queued: true, id: "message-1" }, + }); + expect(restored.sessionQueue("session")).toEqual([ + { + id: "message-1", + message: "Run the tests", + status: "sending", + }, + ]); + await restored.close(); + }); + + it("rejects acknowledgements for a different transport epoch", async () => { + const { value } = await journal(); + value.enqueue({ + type: "response", + requestId: "request-1", + success: true, + data: null, + }); + + await expect( + value.acknowledge({ + connectorEpoch: "different-epoch", + acknowledgedSequence: 1, + }), + ).rejects.toThrow("different connector epoch"); + await value.close(); + }); + + it("removes sessions that are no longer authorized by the server", async () => { + const { value } = await journal(); + await value.recordSession(session); + + await value.retainSessions(new Set()); + + expect(value.sessionIds()).toEqual([]); + await value.close(); + }); +}); diff --git a/apps/connector/src/state.ts b/apps/connector/src/state.ts new file mode 100644 index 00000000..498ac49a --- /dev/null +++ b/apps/connector/src/state.ts @@ -0,0 +1,330 @@ +import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + HOST_CONNECTOR_EVENT_BATCH_LIMIT, + agentPromptImageSchema, + isAgentDaemonSessionDescriptor, + isHostConnectorEvent, + type AgentDaemonSessionDescriptor, + type AgentQueuedMessage, + type HostConnectorEvent, + type HostConnectorEventAck, + type HostConnectorEventPayload, +} from "@overtchat/agent-bridge"; + +export type CachedCommandResult = + | { success: true; data: unknown } + | { success: false; error: string }; + +type SessionState = { + descriptor: AgentDaemonSessionDescriptor; + queuedMessages: AgentQueuedMessage[]; +}; + +type ConnectorState = { + format: 1; + connectorEpoch: string; + nextEventSequence: number; + acknowledgedSequence: number; + events: HostConnectorEvent[]; + commandResults: Array<[string, CachedCommandResult]>; + sessions: Record; +}; + +const COMMAND_RESULT_LIMIT = 2_048; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isQueuedMessage(value: unknown): value is AgentQueuedMessage { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && + value.id.length > 0 && + typeof value.message === "string" && + (value.status === "pending" || value.status === "sending") && + (value.images === undefined || + (Array.isArray(value.images) && + value.images.every((image) => agentPromptImageSchema.safeParse(image).success))) + ); +} + +function isCommandResult(value: unknown): value is CachedCommandResult { + return ( + isRecord(value) && + ((value.success === true && "data" in value) || + (value.success === false && typeof value.error === "string")) + ); +} + +function parseState(value: unknown): ConnectorState { + if ( + !isRecord(value) || + value.format !== 1 || + typeof value.connectorEpoch !== "string" || + !value.connectorEpoch || + !Number.isSafeInteger(value.nextEventSequence) || + Number(value.nextEventSequence) < 0 || + !Number.isSafeInteger(value.acknowledgedSequence) || + Number(value.acknowledgedSequence) < 0 || + !Array.isArray(value.events) || + !value.events.every(isHostConnectorEvent) || + !Array.isArray(value.commandResults) || + !isRecord(value.sessions) + ) { + throw new Error("Invalid Host Connector state journal."); + } + const commandResults: Array<[string, CachedCommandResult]> = []; + for (const entry of value.commandResults) { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== "string" || + !entry[0] || + !isCommandResult(entry[1]) + ) { + throw new Error("Invalid Host Connector command journal."); + } + commandResults.push([entry[0], entry[1]]); + } + const sessions: Record = {}; + for (const [sessionId, session] of Object.entries(value.sessions)) { + if ( + !isRecord(session) || + !isAgentDaemonSessionDescriptor(session.descriptor) || + session.descriptor.sessionId !== sessionId || + !Array.isArray(session.queuedMessages) || + !session.queuedMessages.every(isQueuedMessage) + ) { + throw new Error("Invalid Host Connector session journal."); + } + sessions[sessionId] = { + descriptor: session.descriptor, + queuedMessages: session.queuedMessages, + }; + } + const events = value.events as HostConnectorEvent[]; + const nextEventSequence = Number(value.nextEventSequence); + const acknowledgedSequence = Number(value.acknowledgedSequence); + if ( + acknowledgedSequence > nextEventSequence || + events.some( + (event, index) => + event.sequence <= acknowledgedSequence || + (index > 0 && event.sequence !== events[index - 1]!.sequence + 1), + ) || + (events.at(-1)?.sequence ?? acknowledgedSequence) !== nextEventSequence + ) { + throw new Error("Invalid Host Connector event journal."); + } + return { + format: 1, + connectorEpoch: value.connectorEpoch, + nextEventSequence, + acknowledgedSequence, + events, + commandResults: commandResults.slice(-COMMAND_RESULT_LIMIT), + sessions, + }; +} + +function initialState(): ConnectorState { + return { + format: 1, + connectorEpoch: crypto.randomUUID(), + nextEventSequence: 0, + acknowledgedSequence: 0, + events: [], + commandResults: [], + sessions: {}, + }; +} + +export class ConnectorStateJournal { + private writeTail = Promise.resolve(); + private saveTimer: NodeJS.Timeout | undefined; + + private constructor( + private readonly file: string, + private readonly state: ConnectorState, + ) {} + + static async open(file: string): Promise { + let state: ConnectorState; + try { + state = parseState(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + state = initialState(); + } + return new ConnectorStateJournal(file, state); + } + + get connectorEpoch(): string { + return this.state.connectorEpoch; + } + + enqueue(payload: HostConnectorEventPayload): HostConnectorEvent { + const event = { + sequence: ++this.state.nextEventSequence, + payload, + }; + this.state.events.push(event); + this.scheduleSave(); + return event; + } + + eventBatch(): HostConnectorEvent[] { + return this.state.events.slice(0, HOST_CONNECTOR_EVENT_BATCH_LIMIT); + } + + async acknowledge(ack: HostConnectorEventAck): Promise { + if (ack.connectorEpoch !== this.state.connectorEpoch) { + throw new Error("OvertChat acknowledged a different connector epoch."); + } + if ( + !Number.isSafeInteger(ack.acknowledgedSequence) || + ack.acknowledgedSequence < this.state.acknowledgedSequence || + ack.acknowledgedSequence > this.state.nextEventSequence + ) { + throw new Error("OvertChat returned an invalid connector acknowledgement."); + } + this.state.acknowledgedSequence = ack.acknowledgedSequence; + this.state.events = this.state.events.filter( + (event) => event.sequence > ack.acknowledgedSequence, + ); + await this.save(); + } + + commandResult(commandId: string): CachedCommandResult | undefined { + return new Map(this.state.commandResults).get(commandId); + } + + async recordCommandResult( + commandId: string, + result: CachedCommandResult, + ): Promise { + const existing = this.state.commandResults.findIndex( + ([storedId]) => storedId === commandId, + ); + if (existing >= 0) this.state.commandResults.splice(existing, 1); + this.state.commandResults.push([commandId, result]); + if (this.state.commandResults.length > COMMAND_RESULT_LIMIT) { + this.state.commandResults.splice( + 0, + this.state.commandResults.length - COMMAND_RESULT_LIMIT, + ); + } + await this.save(); + } + + sessionQueue(sessionId: string): readonly AgentQueuedMessage[] { + return this.state.sessions[sessionId]?.queuedMessages ?? []; + } + + sessionIds(): string[] { + return Object.keys(this.state.sessions); + } + + async retainSessions(sessionIds: ReadonlySet): Promise { + this.deleteMatchingSessions( + (session) => !sessionIds.has(session.descriptor.sessionId), + ); + await this.save(); + } + + async recordSession(descriptor: AgentDaemonSessionDescriptor): Promise { + const current = this.state.sessions[descriptor.sessionId]; + this.state.sessions[descriptor.sessionId] = { + descriptor, + queuedMessages: current?.queuedMessages ?? [], + }; + await this.save(); + } + + async saveSessionQueue( + sessionId: string, + messages: readonly AgentQueuedMessage[], + ): Promise { + const current = this.state.sessions[sessionId]; + if (!current) return; + current.queuedMessages = messages.map((message) => ({ + ...message, + ...(message.images ? { images: [...message.images] } : {}), + })); + await this.save(); + } + + async deleteSession(sessionId: string): Promise { + delete this.state.sessions[sessionId]; + await this.save(); + } + + async deleteWorkspace(workspaceId: string): Promise { + this.deleteMatchingSessions( + (session) => session.descriptor.workspaceId === workspaceId, + ); + await this.save(); + } + + async deleteConnection(connectionId: string): Promise { + this.deleteMatchingSessions( + (session) => session.descriptor.connectionId === connectionId, + ); + await this.save(); + } + + async deleteAllSessions(): Promise { + this.state.sessions = {}; + await this.save(); + } + + async close(): Promise { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = undefined; + } + await this.save(); + await this.writeTail; + } + + private deleteMatchingSessions( + predicate: (session: SessionState) => boolean, + ): void { + for (const [sessionId, session] of Object.entries(this.state.sessions)) { + if (predicate(session)) delete this.state.sessions[sessionId]; + } + } + + private scheduleSave(): void { + if (this.saveTimer) return; + this.saveTimer = setTimeout(() => { + this.saveTimer = undefined; + void this.save().catch((error) => { + console.error( + `Unable to write Host Connector state: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, 25); + this.saveTimer.unref(); + } + + private save(): Promise { + const serialized = `${JSON.stringify(this.state)}\n`; + const operation = this.writeTail.then(async () => { + await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 }); + const temporary = `${this.file}.${process.pid}.tmp`; + await writeFile(temporary, serialized, { + encoding: "utf8", + mode: 0o600, + }); + await rename(temporary, this.file); + await chmod(this.file, 0o600); + }); + this.writeTail = operation.catch(() => {}); + return operation; + } +} diff --git a/apps/connector/tsconfig.json b/apps/connector/tsconfig.json index 854464bb..7f0e6e8a 100644 --- a/apps/connector/tsconfig.json +++ b/apps/connector/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM"], + "lib": ["ES2023", "DOM"], "strict": true, "noEmit": true, "isolatedModules": true, diff --git a/apps/site/public/_redirects b/apps/site/public/_redirects index 63396190..5bcb2b48 100644 --- a/apps/site/public/_redirects +++ b/apps/site/public/_redirects @@ -2,7 +2,7 @@ /privacy.html /privacy/ 301 # Generated setup commands use immutable connector versions. -/install/connector/0.1.0 https://github.com/yoloyash/overtchat/releases/download/connector-v0.1.0/install-connector.sh 302 +/install/connector/0.2.0 https://github.com/yoloyash/overtchat/releases/download/connector-v0.2.0/install-connector.sh 302 # Friendly alias for the current connector release. -/install-connector.sh /install/connector/0.1.0 302 +/install-connector.sh /install/connector/0.2.0 302 diff --git a/apps/web/app/(app)/agents/[id]/page.tsx b/apps/web/app/(app)/agents/[id]/page.tsx index af70a68f..4450f345 100644 --- a/apps/web/app/(app)/agents/[id]/page.tsx +++ b/apps/web/app/(app)/agents/[id]/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { AgentSessionView } from "@/components/agents/AgentSessionView"; import { auth } from "@/lib/auth/server"; import { getOwnedAgentSession } from "@/lib/db/agentConnections"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; export default async function AgentSessionPage({ params, diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index ad9bdfc6..a7815a42 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -5,7 +5,7 @@ import { auth } from "@/lib/auth/server"; import { listChats } from "@/lib/db/chats"; import { listProjects } from "@/lib/db/projects"; import { listAgentConnections } from "@/lib/db/agentConnections"; -import { withAgentRuntimeStatuses } from "@/lib/agents/runtime/status"; +import { withAgentRuntimeStatuses } from "@/lib/agents/connector/status"; import { AppShell } from "@/components/AppShell"; import { Sidebar } from "@/components/Sidebar"; import { getQueryClient } from "@/lib/queryClient"; @@ -14,7 +14,7 @@ import { chatKeys, projectKeys, } from "@/lib/queries/keys"; -import type { AgentConnectionListItem } from "@/lib/agents/types"; +import type { AgentConnectionListItem } from "@overtchat/agent-bridge"; import type { ChatListItem } from "@/lib/queries/chats"; import type { ProjectListItem } from "@/lib/queries/projects"; @@ -59,7 +59,6 @@ export default async function AppLayout({ queryFn: async (): Promise => withAgentRuntimeStatuses( await listAgentConnections(session.user.id), - session.user.id, ), }) : Promise.resolve(), diff --git a/apps/web/app/(app)/settings/connections/AddConnectionDialog.tsx b/apps/web/app/(app)/settings/connections/AddConnectionDialog.tsx index 2ec1519f..ba445a2f 100644 --- a/apps/web/app/(app)/settings/connections/AddConnectionDialog.tsx +++ b/apps/web/app/(app)/settings/connections/AddConnectionDialog.tsx @@ -37,11 +37,11 @@ import { type AgentSshHostCandidate, type DetectedAgentInstallation, type HostConnectorListItem, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { AGENT_PROVIDERS, agentProviderMetadata, -} from "@/lib/agents/catalog"; +} from "@overtchat/agent-bridge"; import { useAgentSshHosts, useCreateAgentConnection, diff --git a/apps/web/app/(app)/settings/connections/AddWorkspaceDialog.tsx b/apps/web/app/(app)/settings/connections/AddWorkspaceDialog.tsx index 54c3c4bf..e66f474b 100644 --- a/apps/web/app/(app)/settings/connections/AddWorkspaceDialog.tsx +++ b/apps/web/app/(app)/settings/connections/AddWorkspaceDialog.tsx @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "@/components/ui/toast"; -import type { AgentConnectionListItem } from "@/lib/agents/types"; +import type { AgentConnectionListItem } from "@overtchat/agent-bridge"; import { useAgentDirectories, useCreateAgentWorkspace, diff --git a/apps/web/app/(app)/settings/connections/ConnectionsPanel.tsx b/apps/web/app/(app)/settings/connections/ConnectionsPanel.tsx index 345c47a4..a884bc81 100644 --- a/apps/web/app/(app)/settings/connections/ConnectionsPanel.tsx +++ b/apps/web/app/(app)/settings/connections/ConnectionsPanel.tsx @@ -33,8 +33,8 @@ import type { AgentWorkspaceListItem, HostConnectorListItem, HostConnectorPairing, -} from "@/lib/agents/types"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; +} from "@overtchat/agent-bridge"; +import { agentProviderMetadata } from "@overtchat/agent-bridge"; import { useAgentConnections, useDeleteAgentConnection, diff --git a/apps/web/app/(app)/settings/connections/SshHostPicker.tsx b/apps/web/app/(app)/settings/connections/SshHostPicker.tsx index 062376d7..e60b28e2 100644 --- a/apps/web/app/(app)/settings/connections/SshHostPicker.tsx +++ b/apps/web/app/(app)/settings/connections/SshHostPicker.tsx @@ -8,7 +8,7 @@ import { RefreshCw, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import type { AgentSshHostCandidate } from "@/lib/agents/types"; +import type { AgentSshHostCandidate } from "@overtchat/agent-bridge"; import { cn } from "@/lib/utils"; export function SshHostPicker({ diff --git a/apps/web/app/(app)/settings/connections/page.tsx b/apps/web/app/(app)/settings/connections/page.tsx index e62f09a1..24fa7dcb 100644 --- a/apps/web/app/(app)/settings/connections/page.tsx +++ b/apps/web/app/(app)/settings/connections/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { auth } from "@/lib/auth/server"; import { listAgentConnections } from "@/lib/db/agentConnections"; -import { withAgentRuntimeStatuses } from "@/lib/agents/runtime/status"; +import { withAgentRuntimeStatuses } from "@/lib/agents/connector/status"; import { getQueryClient } from "@/lib/queryClient"; import { agentConnectionKeys } from "@/lib/queries/keys"; import { ConnectionsPanel } from "./ConnectionsPanel"; @@ -25,7 +25,6 @@ export default async function Page({ queryFn: async () => withAgentRuntimeStatuses( await listAgentConnections(session.user.id), - session.user.id, ), }); diff --git a/apps/web/app/api/agent-connections/[id]/directories/route.test.ts b/apps/web/app/api/agent-connections/[id]/directories/route.test.ts index fcad5cc2..688b6e77 100644 --- a/apps/web/app/api/agent-connections/[id]/directories/route.test.ts +++ b/apps/web/app/api/agent-connections/[id]/directories/route.test.ts @@ -3,8 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getOwnedAgentConnection: vi.fn(), - targetForStoredHost: vi.fn(), - listAgentDirectories: vi.fn(), + daemonRequest: vi.fn(), })); vi.mock("server-only", () => ({})); @@ -14,11 +13,8 @@ vi.mock("@/lib/auth/server", () => ({ vi.mock("@/lib/db/agentConnections", () => ({ getOwnedAgentConnection: mocks.getOwnedAgentConnection, })); -vi.mock("@/lib/agents/runtime/target", () => ({ - targetForStoredHost: mocks.targetForStoredHost, -})); -vi.mock("@/lib/agents/runtime/filesystem", () => ({ - listAgentDirectories: mocks.listAgentDirectories, +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { request: mocks.daemonRequest }, })); import { GET } from "./route"; @@ -32,11 +28,10 @@ describe("agent directory route", () => { user: { id: "owner", role: "admin" }, }); mocks.getOwnedAgentConnection.mockResolvedValue({ - host: { transport: "local", userId: "owner" }, - connection: { id: "connection" }, + host: { connectorId: "connector", transport: "local", userId: "owner" }, + connection: { id: "connection", shellMode: "interactive" }, }); - mocks.targetForStoredHost.mockReturnValue({ transport: "local" }); - mocks.listAgentDirectories.mockResolvedValue({ + mocks.daemonRequest.mockResolvedValue({ path: "/home/owner", parent: "/home", directories: [{ name: "code", path: "/home/owner/code" }], @@ -52,11 +47,11 @@ describe("agent directory route", () => { ); expect(response.status).toBe(200); - expect(mocks.targetForStoredHost).toHaveBeenCalled(); - expect(mocks.listAgentDirectories).toHaveBeenCalledWith( - { transport: "local" }, - "/home/owner", - ); + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "list_directories", + target: { transport: "local", shellMode: "interactive" }, + path: "/home/owner", + }); await expect(response.json()).resolves.toMatchObject({ directory: { path: "/home/owner" }, }); @@ -78,6 +73,6 @@ describe("agent directory route", () => { context, ); expect(relative.status).toBe(400); - expect(mocks.listAgentDirectories).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/agent-connections/[id]/directories/route.ts b/apps/web/app/api/agent-connections/[id]/directories/route.ts index 41bcd98b..b3a9400c 100644 --- a/apps/web/app/api/agent-connections/[id]/directories/route.ts +++ b/apps/web/app/api/agent-connections/[id]/directories/route.ts @@ -3,8 +3,8 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { listAgentDirectories } from "@/lib/agents/runtime/filesystem"; -import { targetForStoredHost } from "@/lib/agents/runtime/target"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { daemonTarget } from "@/lib/agents/connector/descriptors"; import { getOwnedAgentConnection } from "@/lib/db/agentConnections"; export const maxDuration = 30; @@ -32,9 +32,13 @@ export async function GET( } try { - const directory = await listAgentDirectories( - targetForStoredHost(owned.host, owned.connection.shellMode), - path, + const directory = await hostConnectorBroker.request( + owned.host.connectorId, + { + type: "list_directories", + target: daemonTarget(owned.host, owned.connection.shellMode), + ...(path ? { path } : {}), + }, ); return Response.json({ directory }); } catch (error) { diff --git a/apps/web/app/api/agent-connections/[id]/route.ts b/apps/web/app/api/agent-connections/[id]/route.ts index 20594f4d..218505c7 100644 --- a/apps/web/app/api/agent-connections/[id]/route.ts +++ b/apps/web/app/api/agent-connections/[id]/route.ts @@ -4,12 +4,10 @@ import { getOwnedAgentConnection, touchAgentConnectionValidation, } from "@/lib/db/agentConnections"; -import { targetForStoredHost } from "@/lib/agents/runtime/target"; -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; import { connectionErrorMessage } from "@/lib/agents/access"; import { storedConnectionAccessError } from "@/lib/agents/access"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; export const maxDuration = 150; @@ -31,12 +29,27 @@ export async function POST( if (!isAgentProviderId(owned.connection.provider)) { throw new Error("This coding-agent provider is not supported."); } - const probe = await agentProviderAdapter( - owned.connection.provider, - ).probeTarget( - targetForStoredHost(owned.host, owned.connection.shellMode), - owned.connection.executable, - ); + const draft = + owned.host.transport === "local" + ? { + connectorId: owned.host.connectorId, + provider: owned.connection.provider, + name: owned.host.name, + executable: owned.connection.executable, + transport: "local" as const, + } + : { + connectorId: owned.host.connectorId, + provider: owned.connection.provider, + name: owned.host.name, + executable: owned.connection.executable, + transport: "ssh" as const, + sshAlias: owned.host.sshAlias ?? "", + }; + const probe = await hostConnectorBroker.request<{ + version: string; + shellMode: "interactive" | "login"; + }>(owned.host.connectorId, { type: "probe", draft }); await touchAgentConnectionValidation( id, session.user.id, @@ -70,7 +83,14 @@ export async function DELETE( return Response.json({ error: accessError }, { status: 403 }); } const { id } = await params; - await agentRuntimeRegistry.stopConnection(id, session.user.id); + const owned = await getOwnedAgentConnection(id, session.user.id); + if (!owned) return new Response("Not found", { status: 404 }); + await hostConnectorBroker + .request(owned.host.connectorId, { + type: "stop_connection", + connectionId: id, + }) + .catch(() => {}); const deleted = await deleteAgentConnection(id, session.user.id); return deleted ? new Response(null, { status: 204 }) diff --git a/apps/web/app/api/agent-connections/[id]/workspaces/route.ts b/apps/web/app/api/agent-connections/[id]/workspaces/route.ts index 8d111d03..578c89e8 100644 --- a/apps/web/app/api/agent-connections/[id]/workspaces/route.ts +++ b/apps/web/app/api/agent-connections/[id]/workspaces/route.ts @@ -3,11 +3,13 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { addAgentWorkspaceSchema } from "@/lib/agents/types"; -import { probeAgentWorkspace } from "@/lib/agents/runtime/filesystem"; -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; -import { targetForStoredHost } from "@/lib/agents/runtime/target"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { addAgentWorkspaceSchema } from "@overtchat/agent-bridge"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { + daemonTarget, + parseProviderSessionMetadata, +} from "@/lib/agents/connector/descriptors"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; import { createAgentWorkspace, getOwnedAgentConnection, @@ -43,24 +45,46 @@ export async function POST( if (!isAgentProviderId(owned.connection.provider)) { throw new Error("This coding-agent provider is not supported."); } - const target = targetForStoredHost( - owned.host, - owned.connection.shellMode, - ); - const workspace = await probeAgentWorkspace(target, parsed.data.path); - const providerSessions = await agentProviderAdapter( - owned.connection.provider, - ).listWorkspaceSessions( + const target = daemonTarget(owned.host, owned.connection.shellMode); + const workspace = await hostConnectorBroker.request<{ + path: string; + name: string; + }>(owned.host.connectorId, { + type: "probe_workspace", target, - owned.connection.executable, - workspace.path, - ); - const row = await createAgentWorkspace(id, session.user.id, { - path: workspace.path, - name: parsed.data.name ?? workspace.name, + path: parsed.data.path, }); + const workspaceId = crypto.randomUUID(); + const providerSessions = await hostConnectorBroker.request( + owned.host.connectorId, + { + type: "list_sessions", + workspace: { + connectionId: owned.connection.id, + workspaceId, + provider: owned.connection.provider, + target, + executable: owned.connection.executable, + cwd: workspace.path, + detectedVersion: owned.connection.detectedVersion, + }, + }, + ); + const metadata = providerSessions.map(parseProviderSessionMetadata); + const row = await createAgentWorkspace( + id, + session.user.id, + { + path: workspace.path, + name: parsed.data.name ?? workspace.name, + }, + workspaceId, + ); if (!row) return new Response("Not found", { status: 404 }); - const sessions = syncAgentWorkspaceSessions(row.id, providerSessions); + const sessions = syncAgentWorkspaceSessions( + row.id, + metadata, + ); return Response.json( { workspace: { diff --git a/apps/web/app/api/agent-connections/discover/route.test.ts b/apps/web/app/api/agent-connections/discover/route.test.ts index f2e7d54a..6700da5a 100644 --- a/apps/web/app/api/agent-connections/discover/route.test.ts +++ b/apps/web/app/api/agent-connections/discover/route.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - discover: vi.fn(), + daemonRequest: vi.fn(), getOwnedConnector: vi.fn(), getSession: vi.fn(), })); @@ -10,8 +10,8 @@ vi.mock("server-only", () => ({})); vi.mock("@/lib/auth/server", () => ({ auth: { api: { getSession: mocks.getSession } }, })); -vi.mock("@/lib/agents/runtime/discovery", () => ({ - discoverAgentInstallations: mocks.discover, +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { request: mocks.daemonRequest }, })); vi.mock("@/lib/db/hostConnectors", () => ({ getOwnedHostConnector: mocks.getOwnedConnector, @@ -37,7 +37,7 @@ describe("Agent Connection discovery route", () => { user: { id: "admin", role: "admin" }, }); mocks.getOwnedConnector.mockReturnValue({ id: "connector" }); - mocks.discover.mockResolvedValue([ + mocks.daemonRequest.mockResolvedValue([ { provider: "omp", executable: "/home/admin/.bun/bin/omp", @@ -69,10 +69,13 @@ describe("Agent Connection discovery route", () => { "connector", "admin", ); - expect(mocks.discover).toHaveBeenCalledWith({ - connectorId: "connector", - transport: "ssh", - alias: "macbook", + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "discover", + target: { + connectorId: "connector", + transport: "ssh", + sshAlias: "macbook", + }, }); }); @@ -84,7 +87,7 @@ describe("Agent Connection discovery route", () => { ); expect(response.status).toBe(404); - expect(mocks.discover).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); it("keeps discovery restricted to administrators", async () => { @@ -98,6 +101,6 @@ describe("Agent Connection discovery route", () => { expect(response.status).toBe(403); expect(mocks.getOwnedConnector).not.toHaveBeenCalled(); - expect(mocks.discover).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/agent-connections/discover/route.ts b/apps/web/app/api/agent-connections/discover/route.ts index f00b91ac..e5ef7f62 100644 --- a/apps/web/app/api/agent-connections/discover/route.ts +++ b/apps/web/app/api/agent-connections/discover/route.ts @@ -3,26 +3,12 @@ import { connectionAccessError, connectionErrorMessage, } from "@/lib/agents/access"; -import { - agentDiscoveryTargetSchema, - type AgentDiscoveryTarget, -} from "@/lib/agents/types"; -import { discoverAgentInstallations } from "@/lib/agents/runtime/discovery"; -import type { HostTarget } from "@/lib/agents/runtime/process"; +import { agentDiscoveryTargetSchema } from "@overtchat/agent-bridge"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; import { getOwnedHostConnector } from "@/lib/db/hostConnectors"; export const maxDuration = 30; -function hostTarget(target: AgentDiscoveryTarget): HostTarget { - return target.transport === "local" - ? { connectorId: target.connectorId, transport: "local" } - : { - connectorId: target.connectorId, - transport: "ssh", - alias: target.sshAlias, - }; -} - export async function POST(request: Request) { const session = await auth.api.getSession({ headers: request.headers }); if (!session) return new Response("Unauthorized", { status: 401 }); @@ -46,8 +32,9 @@ export async function POST(request: Request) { try { return Response.json({ - installations: await discoverAgentInstallations( - hostTarget(parsed.data), + installations: await hostConnectorBroker.request( + parsed.data.connectorId, + { type: "discover", target: parsed.data }, ), }); } catch (error) { diff --git a/apps/web/app/api/agent-connections/probe/route.ts b/apps/web/app/api/agent-connections/probe/route.ts index 14165ea6..0e51660c 100644 --- a/apps/web/app/api/agent-connections/probe/route.ts +++ b/apps/web/app/api/agent-connections/probe/route.ts @@ -3,8 +3,8 @@ import { connectionAccessError, connectionErrorMessage, } from "@/lib/agents/access"; -import { agentConnectionDraftSchema } from "@/lib/agents/types"; -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; +import { agentConnectionDraftSchema } from "@overtchat/agent-bridge"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; import { getOwnedHostConnector } from "@/lib/db/hostConnectors"; export const maxDuration = 150; @@ -31,9 +31,10 @@ export async function POST(req: Request) { } try { - const probe = await agentProviderAdapter( - parsed.data.provider, - ).probeConnection(parsed.data); + const probe = await hostConnectorBroker.request( + parsed.data.connectorId, + { type: "probe", draft: parsed.data }, + ); return Response.json({ probe }); } catch (error) { return Response.json( diff --git a/apps/web/app/api/agent-connections/route.test.ts b/apps/web/app/api/agent-connections/route.test.ts index 38597024..06631117 100644 --- a/apps/web/app/api/agent-connections/route.test.ts +++ b/apps/web/app/api/agent-connections/route.test.ts @@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => ({ getSession: vi.fn(), listAgentConnections: vi.fn(), createAgentConnection: vi.fn(), - probeConnection: vi.fn(), + daemonRequest: vi.fn(), getOwnedHostConnector: vi.fn(), withAgentRuntimeStatuses: vi.fn(), })); @@ -17,15 +17,13 @@ vi.mock("@/lib/db/agentConnections", () => ({ listAgentConnections: mocks.listAgentConnections, createAgentConnection: mocks.createAgentConnection, })); -vi.mock("@/lib/agents/providers/registry", () => ({ - agentProviderAdapter: () => ({ - probeConnection: mocks.probeConnection, - }), +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { request: mocks.daemonRequest }, })); vi.mock("@/lib/db/hostConnectors", () => ({ getOwnedHostConnector: mocks.getOwnedHostConnector, })); -vi.mock("@/lib/agents/runtime/status", () => ({ +vi.mock("@/lib/agents/connector/status", () => ({ withAgentRuntimeStatuses: mocks.withAgentRuntimeStatuses, })); @@ -62,7 +60,7 @@ describe("Agent Connections route", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ connections: [] }); expect(mocks.listAgentConnections).toHaveBeenCalledWith("admin"); - expect(mocks.withAgentRuntimeStatuses).toHaveBeenCalledWith([], "admin"); + expect(mocks.withAgentRuntimeStatuses).toHaveBeenCalledWith([]); }); it("does not expose connections to non-admin users", async () => { @@ -93,12 +91,12 @@ describe("Agent Connections route", () => { ); expect(response.status).toBe(403); - expect(mocks.probeConnection).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); expect(mocks.createAgentConnection).not.toHaveBeenCalled(); }); it("persists the shell mode selected during connection probing", async () => { - mocks.probeConnection.mockResolvedValue({ + mocks.daemonRequest.mockResolvedValue({ status: "ready", version: "17.2.11", models: [], diff --git a/apps/web/app/api/agent-connections/route.ts b/apps/web/app/api/agent-connections/route.ts index 5730908f..3baeb272 100644 --- a/apps/web/app/api/agent-connections/route.ts +++ b/apps/web/app/api/agent-connections/route.ts @@ -6,9 +6,9 @@ import { import { agentConnectionDraftSchema, type AgentConnectionListItem, -} from "@/lib/agents/types"; -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; -import { withAgentRuntimeStatuses } from "@/lib/agents/runtime/status"; +} from "@overtchat/agent-bridge"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { withAgentRuntimeStatuses } from "@/lib/agents/connector/status"; import { getOwnedHostConnector } from "@/lib/db/hostConnectors"; import { createAgentConnection, @@ -27,7 +27,6 @@ export async function GET(req: Request) { return Response.json({ connections: withAgentRuntimeStatuses( await listAgentConnections(session.user.id), - session.user.id, ), }); } @@ -55,9 +54,12 @@ export async function POST(req: Request) { } try { - const probe = await agentProviderAdapter( - draft.provider, - ).probeConnection(draft); + const probe = await hostConnectorBroker.request<{ + status: "ready"; + version: string; + models: unknown[]; + shellMode: "interactive" | "login"; + }>(draft.connectorId, { type: "probe", draft }); const owned = createAgentConnection({ userId: session.user.id, host: @@ -82,7 +84,6 @@ export async function POST(req: Request) { }); const connections = withAgentRuntimeStatuses( await listAgentConnections(session.user.id), - session.user.id, ); const connection = connections.find( (candidate) => candidate.id === owned.connection.id, diff --git a/apps/web/app/api/agent-sessions/[id]/events/route.test.ts b/apps/web/app/api/agent-sessions/[id]/events/route.test.ts index 477e6989..21e4db69 100644 --- a/apps/web/app/api/agent-sessions/[id]/events/route.test.ts +++ b/apps/web/app/api/agent-sessions/[id]/events/route.test.ts @@ -3,8 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getOwnedAgentSession: vi.fn(), - getOrStart: vi.fn(), - subscribe: vi.fn(), + subscribeSession: vi.fn(), unsubscribe: vi.fn(), })); @@ -15,12 +14,47 @@ vi.mock("@/lib/auth/server", () => ({ vi.mock("@/lib/db/agentConnections", () => ({ getOwnedAgentSession: mocks.getOwnedAgentSession, })); -vi.mock("@/lib/agents/runtime/registry", () => ({ - agentRuntimeRegistry: { getOrStart: mocks.getOrStart }, +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { subscribeSession: mocks.subscribeSession }, })); import { GET } from "./route"; +const owned = { + host: { + connectorId: "connector", + transport: "local", + sshAlias: null, + userId: "owner", + }, + connection: { + id: "connection", + provider: "pi", + shellMode: "interactive", + executable: "pi", + detectedVersion: "0.55.0", + }, + workspace: { id: "workspace", path: "/workspace" }, + agentSession: { + id: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", + }, +}; + +const sessionDescriptor = { + connectionId: "connection", + workspaceId: "workspace", + provider: "pi", + target: { transport: "local", shellMode: "interactive" }, + executable: "pi", + cwd: "/workspace", + detectedVersion: "0.55.0", + sessionId: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", +}; + const context = { params: Promise.resolve({ id: "session" }) }; describe("agent session event stream", () => { @@ -29,17 +63,17 @@ describe("agent session event stream", () => { mocks.getSession.mockResolvedValue({ user: { id: "owner", role: "admin" }, }); - mocks.getOwnedAgentSession.mockResolvedValue({ - host: { transport: "local", userId: "owner" }, - }); - mocks.getOrStart.mockResolvedValue({ subscribe: mocks.subscribe }); - mocks.subscribe.mockImplementation( - ( + mocks.getOwnedAgentSession.mockResolvedValue(owned); + mocks.subscribeSession.mockImplementation( + async ( + _connectorId: string, + _session: unknown, + after: { epoch: string; sequence: number } | undefined, listener: (event: Record) => void, - afterSequence: number, ) => { listener({ - sequence: afterSequence + 1, + epoch: "runtime", + sequence: (after?.sequence ?? 0) + 1, type: "snapshot", data: { sessionId: "session", status: "idle" }, }); @@ -48,10 +82,10 @@ describe("agent session event stream", () => { ); }); - it("passes Last-Event-ID into replay and emits valid SSE", async () => { + it("passes the runtime cursor into replay and emits valid SSE", async () => { const response = await GET( new Request("http://server.test/events", { - headers: { "Last-Event-ID": "7" }, + headers: { "Last-Event-ID": "runtime:7" }, }), context, ); @@ -63,17 +97,20 @@ describe("agent session event stream", () => { const reader = response.body!.getReader(); const first = await reader.read(); const text = new TextDecoder().decode(first.value); - expect(text).toContain("id: 8"); + expect(text).toContain("id: runtime:8"); expect(text).toContain("event: runtime"); - expect(mocks.subscribe).toHaveBeenCalledWith( + expect(mocks.subscribeSession).toHaveBeenCalledWith( + "connector", + sessionDescriptor, + { epoch: "runtime", sequence: 7 }, + expect.any(Function), expect.any(Function), - 7, ); await reader.cancel(); expect(mocks.unsubscribe).toHaveBeenCalled(); }); - it("does not start a runtime for a session owned by another user", async () => { + it("does not subscribe to a session owned by another user", async () => { mocks.getOwnedAgentSession.mockResolvedValue(null); const response = await GET( @@ -82,6 +119,6 @@ describe("agent session event stream", () => { ); expect(response.status).toBe(404); - expect(mocks.getOrStart).not.toHaveBeenCalled(); + expect(mocks.subscribeSession).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/agent-sessions/[id]/events/route.ts b/apps/web/app/api/agent-sessions/[id]/events/route.ts index 6a0d36ac..1aec38fb 100644 --- a/apps/web/app/api/agent-sessions/[id]/events/route.ts +++ b/apps/web/app/api/agent-sessions/[id]/events/route.ts @@ -3,22 +3,34 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { - agentRuntimeRegistry, -} from "@/lib/agents/runtime/registry"; -import type { AgentRuntimeEnvelope } from "@/lib/agents/types"; +import { daemonSession } from "@/lib/agents/connector/descriptors"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import type { AgentRuntimeEnvelope } from "@overtchat/agent-bridge"; import { getOwnedAgentSession } from "@/lib/db/agentConnections"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; export const dynamic = "force-dynamic"; export const maxDuration = 300; function encodeEvent(envelope: AgentRuntimeEnvelope): Uint8Array { return new TextEncoder().encode( - `id: ${envelope.sequence}\nevent: runtime\ndata: ${JSON.stringify(envelope)}\n\n`, + `id: ${envelope.epoch}:${envelope.sequence}\nevent: runtime\ndata: ${JSON.stringify(envelope)}\n\n`, ); } +function parseCursor(value: string | null): + | { epoch: string; sequence: number } + | undefined { + if (!value) return undefined; + const separator = value.lastIndexOf(":"); + if (separator <= 0) return undefined; + const epoch = value.slice(0, separator); + const sequence = Number(value.slice(separator + 1)); + return Number.isSafeInteger(sequence) && sequence >= 0 + ? { epoch, sequence } + : undefined; +} + export async function GET( req: Request, { params }: { params: Promise<{ id: string }> }, @@ -33,47 +45,61 @@ export async function GET( const owned = await getOwnedAgentSession(id, session.user.id); if (!owned) return new Response("Not found", { status: 404 }); + const pending: AgentRuntimeEnvelope[] = []; + let controller: ReadableStreamDefaultController | null = null; + let closed = false; + let closeStream = () => {}; try { - const runtime = await agentRuntimeRegistry.getOrStart(owned); - const lastEventId = Number(req.headers.get("last-event-id") ?? "0"); - let close = () => {}; + const unsubscribe = await hostConnectorBroker.subscribeSession( + owned.host.connectorId, + daemonSession(owned), + parseCursor(req.headers.get("last-event-id")), + (envelope) => { + if (closed) return; + if (!controller) { + pending.push(envelope); + return; + } + try { + controller.enqueue(encodeEvent(envelope)); + } catch { + closeStream(); + } + }, + () => closeStream(), + ); + const body = new ReadableStream({ - start(controller) { - let closed = false; - let unsubscribe = () => {}; - const finish = () => { + start(streamController) { + controller = streamController; + for (const envelope of pending.splice(0)) { + streamController.enqueue(encodeEvent(envelope)); + } + const keepAlive = setInterval(() => { if (closed) return; - closed = true; - clearInterval(keepAlive); - unsubscribe(); try { - controller.close(); + streamController.enqueue( + new TextEncoder().encode(": keepalive\n\n"), + ); } catch { - // The browser may already have closed its side of the stream. - } - }; - const keepAlive = setInterval(() => { - if (!closed) { - try { - controller.enqueue(new TextEncoder().encode(": keepalive\n\n")); - } catch { - finish(); - } + closeStream(); } }, 15_000); - unsubscribe = runtime.subscribe((envelope) => { + closeStream = () => { if (closed) return; + closed = true; + clearInterval(keepAlive); + unsubscribe(); try { - controller.enqueue(encodeEvent(envelope)); + streamController.close(); } catch { - finish(); + // The browser may already have closed the stream. } - }, Number.isFinite(lastEventId) ? lastEventId : 0); - req.signal.addEventListener("abort", finish, { once: true }); - close = finish; + }; + req.signal.addEventListener("abort", closeStream, { once: true }); }, cancel() { - close(); + closeStream(); }, }); return new Response(body, { @@ -85,6 +111,7 @@ export async function GET( }, }); } catch (error) { + closed = true; return Response.json( { error: connectionErrorMessage( diff --git a/apps/web/app/api/agent-sessions/[id]/route.test.ts b/apps/web/app/api/agent-sessions/[id]/route.test.ts index c281d1f2..91ed68a0 100644 --- a/apps/web/app/api/agent-sessions/[id]/route.test.ts +++ b/apps/web/app/api/agent-sessions/[id]/route.test.ts @@ -4,11 +4,8 @@ const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getOwnedAgentSession: vi.fn(), updateAgentSessionMetadata: vi.fn(), - getOrStart: vi.fn(), - create: vi.fn(), - command: vi.fn(), - normalizeCommand: vi.fn(), - snapshot: vi.fn(), + upsertAgentSession: vi.fn(), + daemonRequest: vi.fn(), })); vi.mock("server-only", () => ({})); @@ -18,27 +15,57 @@ vi.mock("@/lib/auth/server", () => ({ vi.mock("@/lib/db/agentConnections", () => ({ getOwnedAgentSession: mocks.getOwnedAgentSession, updateAgentSessionMetadata: mocks.updateAgentSessionMetadata, + upsertAgentSession: mocks.upsertAgentSession, })); -vi.mock("@/lib/agents/runtime/registry", () => ({ - agentRuntimeRegistry: { - getOrStart: mocks.getOrStart, - create: mocks.create, - }, +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { request: mocks.daemonRequest }, })); import { GET, POST } from "./route"; const owned = { - host: { transport: "local", userId: "owner" }, - connection: { id: "connection" }, - workspace: { id: "workspace" }, - agentSession: { id: "session", firstMessage: null }, + host: { + connectorId: "connector", + transport: "local", + sshAlias: null, + userId: "owner", + }, + connection: { + id: "connection", + provider: "pi", + shellMode: "interactive", + executable: "pi", + detectedVersion: "0.55.0", + }, + workspace: { id: "workspace", path: "/workspace" }, + agentSession: { + id: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", + firstMessage: null, + }, +}; + +const sessionDescriptor = { + connectionId: "connection", + workspaceId: "workspace", + provider: "pi", + target: { transport: "local", shellMode: "interactive" }, + executable: "pi", + cwd: "/workspace", + detectedVersion: "0.55.0", + sessionId: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", +}; + +const snapshot = { + sessionId: "session", + status: "idle", + queuedMessages: [], }; -function request( - method = "GET", - body?: Record, -): Request { +function request(method = "GET", body?: Record): Request { return new Request("http://server.test/api/agent-sessions/session", { method, ...(body @@ -59,21 +86,13 @@ describe("agent session route", () => { user: { id: "owner", role: "admin" }, }); mocks.getOwnedAgentSession.mockResolvedValue(owned); - mocks.getOrStart.mockResolvedValue({ - command: mocks.command, - normalizeCommand: mocks.normalizeCommand, - snapshot: mocks.snapshot, - }); - mocks.normalizeCommand.mockImplementation((command) => command); - mocks.snapshot.mockReturnValue({ - sessionId: "session", - status: "idle", - queuedMessages: [], - }); - mocks.create.mockResolvedValue({ - sessionId: "new-session", - runtime: { snapshot: vi.fn() }, - }); + mocks.daemonRequest.mockImplementation( + async (_connectorId: string, command: { type: string }) => + command.type === "open_session" + ? { snapshot } + : { commandResult: null, snapshot }, + ); + mocks.upsertAgentSession.mockResolvedValue({ id: "new-session" }); }); it("requires authentication and owner-scoped persistence", async () => { @@ -85,7 +104,7 @@ describe("agent session route", () => { }); mocks.getOwnedAgentSession.mockResolvedValueOnce(null); expect((await GET(request(), context)).status).toBe(404); - expect(mocks.getOrStart).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); it("blocks non-admin users from Agent Connections", async () => { @@ -96,19 +115,41 @@ describe("agent session route", () => { const response = await GET(request(), context); expect(response.status).toBe(403); - expect(mocks.getOrStart).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); - it("validates and forwards native Pi commands", async () => { + it("opens the connector-owned session", async () => { + const response = await GET(request(), context); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ snapshot }); + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "open_session", + session: sessionDescriptor, + }); + }); + + it("forwards submissions with the browser message identity intact", async () => { const response = await POST( - request("POST", { type: "prompt", message: "Inspect this repo" }), + request("POST", { + type: "prompt", + message: "Inspect this repo", + clientMessageId: "message-1", + }), context, ); expect(response.status).toBe(200); - expect(mocks.command).toHaveBeenCalledWith({ - type: "prompt", - message: "Inspect this repo", + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "session_command", + commandId: "message-1", + clientMessageId: "message-1", + session: sessionDescriptor, + command: { + type: "prompt", + message: "Inspect this repo", + clientMessageId: "message-1", + }, }); expect(mocks.updateAgentSessionMetadata).toHaveBeenCalledWith( "session", @@ -133,63 +174,43 @@ describe("agent session route", () => { type: "prompt", message: "", images: [image], + clientMessageId: "message-image", }), context, ); expect(response.status).toBe(200); - expect(mocks.command).toHaveBeenCalledWith({ - type: "prompt", - message: "", - images: [image], - }); + expect(mocks.daemonRequest).toHaveBeenCalledWith( + "connector", + expect.objectContaining({ + type: "session_command", + commandId: "message-image", + command: expect.objectContaining({ images: [image] }), + }), + ); expect(mocks.updateAgentSessionMetadata).toHaveBeenCalledWith( "session", expect.objectContaining({ firstMessage: "screen.png" }), ); }); - it("executes built-in slash commands without recording prompt metadata", async () => { - mocks.normalizeCommand.mockReturnValue({ - type: "set_session_name", - name: "Release prep", - }); - - const response = await POST( - request("POST", { type: "prompt", message: "/name Release prep" }), - context, - ); - - expect(response.status).toBe(200); - expect(mocks.command).toHaveBeenCalledWith({ - type: "set_session_name", - name: "Release prep", - }); - expect(mocks.updateAgentSessionMetadata).toHaveBeenCalledWith("session", { - name: "Release prep", - }); - expect(mocks.updateAgentSessionMetadata).not.toHaveBeenCalledWith( - "session", - expect.objectContaining({ firstMessage: expect.anything() }), - ); - }); - it("forwards provider-neutral queue commands", async () => { - mocks.snapshot.mockReturnValue({ - sessionId: "session", - status: "running", - queuedMessages: [ - { - id: "session:1", - message: "Then summarize", - status: "pending", - }, - ], + const queuedMessages = [ + { + id: "queue-message", + message: "Then summarize", + status: "pending", + }, + ]; + mocks.daemonRequest.mockResolvedValue({ + commandResult: null, + snapshot: { ...snapshot, status: "running", queuedMessages }, }); const queued = await POST( request("POST", { type: "queue", message: "Then summarize", + clientMessageId: "queue-message", }), context, ); @@ -197,18 +218,21 @@ describe("agent session route", () => { expect(queued.status).toBe(200); await expect(queued.json()).resolves.toEqual({ accepted: true, - queuedMessages: [ - { - id: "session:1", + queuedMessages, + }); + expect(mocks.daemonRequest).toHaveBeenCalledWith( + "connector", + expect.objectContaining({ + type: "session_command", + commandId: "queue-message", + clientMessageId: "queue-message", + command: { + type: "queue", message: "Then summarize", - status: "pending", + clientMessageId: "queue-message", }, - ], - }); - expect(mocks.command).toHaveBeenCalledWith({ - type: "queue", - message: "Then summarize", - }); + }), + ); expect(mocks.updateAgentSessionMetadata).not.toHaveBeenCalled(); }); @@ -217,26 +241,41 @@ describe("agent session route", () => { request("POST", { type: "steer", message: "Focus on the failing test", + clientMessageId: "steer-message", }), context, ); expect(response.status).toBe(200); - expect(mocks.command).toHaveBeenCalledWith({ - type: "steer", - message: "Focus on the failing test", - }); + expect(mocks.daemonRequest).toHaveBeenCalledWith( + "connector", + expect.objectContaining({ + commandId: "steer-message", + command: expect.objectContaining({ type: "steer" }), + }), + ); expect(mocks.updateAgentSessionMetadata).toHaveBeenCalledWith( "session", { providerModifiedAt: expect.any(Date) }, ); }); - it("creates a new workspace session for /new without prompting Pi", async () => { - mocks.normalizeCommand.mockReturnValue({ type: "new_session" }); + it("creates a new connector-owned workspace session", async () => { + mocks.daemonRequest.mockResolvedValue({ + session: { + providerSessionId: "new-provider-session", + providerSessionPath: "/sessions/new-provider-session.jsonl", + name: null, + firstMessage: null, + messageCount: 0, + createdAt: null, + modifiedAt: null, + }, + snapshot: { ...snapshot, sessionId: "new-session" }, + }); const response = await POST( - request("POST", { type: "prompt", message: "/new" }), + request("POST", { type: "new_session" }), context, ); @@ -245,8 +284,24 @@ describe("agent session route", () => { accepted: true, sessionId: "new-session", }); - expect(mocks.create).toHaveBeenCalledWith(owned); - expect(mocks.command).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "create_session", + sessionId: expect.any(String), + workspace: { + connectionId: "connection", + workspaceId: "workspace", + provider: "pi", + target: { transport: "local", shellMode: "interactive" }, + executable: "pi", + cwd: "/workspace", + detectedVersion: "0.55.0", + }, + }); + expect(mocks.upsertAgentSession).toHaveBeenCalledWith( + "workspace", + expect.objectContaining({ providerSessionId: "new-provider-session" }), + expect.any(String), + ); expect(mocks.updateAgentSessionMetadata).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/agent-sessions/[id]/route.ts b/apps/web/app/api/agent-sessions/[id]/route.ts index 536bc96f..0d73ab71 100644 --- a/apps/web/app/api/agent-sessions/[id]/route.ts +++ b/apps/web/app/api/agent-sessions/[id]/route.ts @@ -3,14 +3,20 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { agentSessionCommandSchema } from "@/lib/agents/types"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; +import { agentSessionCommandSchema } from "@overtchat/agent-bridge"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { + daemonSession, + daemonWorkspace, + parseProviderSessionMetadata, +} from "@/lib/agents/connector/descriptors"; import { getOwnedAgentSession, type OwnedAgentSession, updateAgentSessionMetadata, + upsertAgentSession, } from "@/lib/db/agentConnections"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; export const maxDuration = 300; @@ -45,8 +51,13 @@ export async function GET( const authorized = await authorize(req, id); if ("error" in authorized) return authorized.error; try { - const runtime = await agentRuntimeRegistry.getOrStart(authorized.owned); - return Response.json({ snapshot: runtime.snapshot() }); + const result = await hostConnectorBroker.request<{ + snapshot: unknown; + }>(authorized.owned.host.connectorId, { + type: "open_session", + session: daemonSession(authorized.owned), + }); + return Response.json({ snapshot: result.snapshot }); } catch (error) { return Response.json( { @@ -80,54 +91,80 @@ export async function POST( } try { - const runtime = await agentRuntimeRegistry.getOrStart(authorized.owned); - const normalized = runtime.normalizeCommand(parsed.data); - if (normalized.type === "new_session") { - const created = await agentRuntimeRegistry.create(authorized.owned); + const command = parsed.data; + if (command.type === "new_session") { + const sessionId = crypto.randomUUID(); + const created = await hostConnectorBroker.request<{ + session: unknown; + snapshot: unknown; + }>(authorized.owned.host.connectorId, { + type: "create_session", + sessionId, + workspace: daemonWorkspace(authorized.owned), + }); + const row = await upsertAgentSession( + authorized.owned.workspace.id, + parseProviderSessionMetadata(created.session), + sessionId, + ); return Response.json({ accepted: true, - sessionId: created.sessionId, + sessionId: row.id, }); } - if ( - normalized.type === "edit_message" || - normalized.type === "fork_message" - ) { - const created = await agentRuntimeRegistry.fork( - authorized.owned, - runtime, - normalized, + const clientMessageId = + "clientMessageId" in command ? command.clientMessageId : undefined; + const result = await hostConnectorBroker.request<{ + commandResult?: unknown; + snapshot?: { queuedMessages?: unknown[] }; + fork?: { session: unknown; draft?: string }; + }>(authorized.owned.host.connectorId, { + type: "session_command", + commandId: clientMessageId ?? crypto.randomUUID(), + ...(clientMessageId ? { clientMessageId } : {}), + session: daemonSession(authorized.owned), + command, + }); + if (result.fork) { + const row = await upsertAgentSession( + authorized.owned.workspace.id, + parseProviderSessionMetadata(result.fork.session), ); - return Response.json({ accepted: true, ...created }); + return Response.json({ + accepted: true, + sessionId: row.id, + ...(result.fork.draft !== undefined + ? { draft: result.fork.draft } + : {}), + }); } - const commandResult = await runtime.command(normalized); if ( - normalized.type === "prompt" || - normalized.type === "implement_plan" || - normalized.type === "steer" || - normalized.type === "steer_queued_message" + command.type === "prompt" || + command.type === "implement_plan" || + command.type === "steer" || + command.type === "steer_queued_message" ) { await updateAgentSessionMetadata(id, { ...( - normalized.type === "prompt" && + command.type === "prompt" && !authorized.owned.agentSession.firstMessage ? { firstMessage: - normalized.message || - normalized.images?.[0]?.filename || + command.message || + command.images?.[0]?.filename || "Image attachment", } : {}), providerModifiedAt: new Date(), }); - } else if (normalized.type === "set_session_name") { - await updateAgentSessionMetadata(id, { name: normalized.name }); + } else if (command.type === "set_session_name") { + await updateAgentSessionMetadata(id, { name: command.name }); } return Response.json({ accepted: true, - queuedMessages: runtime.snapshot().queuedMessages, - ...(normalized.type === "show_usage" - ? { usage: commandResult } + queuedMessages: result.snapshot?.queuedMessages, + ...(command.type === "show_usage" + ? { usage: result.commandResult } : {}), }); } catch (error) { diff --git a/apps/web/app/api/agent-workspaces/[id]/git-status/route.test.ts b/apps/web/app/api/agent-workspaces/[id]/git-status/route.test.ts index a17f7de3..e371ff3e 100644 --- a/apps/web/app/api/agent-workspaces/[id]/git-status/route.test.ts +++ b/apps/web/app/api/agent-workspaces/[id]/git-status/route.test.ts @@ -3,8 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getOwnedAgentWorkspace: vi.fn(), - targetForStoredHost: vi.fn(), - inspectAgentWorkspaceGitStatus: vi.fn(), + daemonRequest: vi.fn(), })); vi.mock("server-only", () => ({})); @@ -14,11 +13,8 @@ vi.mock("@/lib/auth/server", () => ({ vi.mock("@/lib/db/agentConnections", () => ({ getOwnedAgentWorkspace: mocks.getOwnedAgentWorkspace, })); -vi.mock("@/lib/agents/runtime/target", () => ({ - targetForStoredHost: mocks.targetForStoredHost, -})); -vi.mock("@/lib/agents/runtime/git", () => ({ - inspectAgentWorkspaceGitStatus: mocks.inspectAgentWorkspaceGitStatus, +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { request: mocks.daemonRequest }, })); import { GET } from "./route"; @@ -35,18 +31,14 @@ describe("agent workspace Git status route", () => { user: { id: "owner", role: "admin" }, }); mocks.getOwnedAgentWorkspace.mockResolvedValue({ - host: { transport: "local", userId: "owner" }, + host: { connectorId: "connector", transport: "local", userId: "owner" }, connection: { id: "connection", shellMode: "interactive" }, workspace: { id: "workspace", path: "/srv/project", }, }); - mocks.targetForStoredHost.mockReturnValue({ - connectorId: "connector", - transport: "local", - }); - mocks.inspectAgentWorkspaceGitStatus.mockResolvedValue({ + mocks.daemonRequest.mockResolvedValue({ isGit: true, repositoryRoot: "/srv/project", branch: "feature/status", @@ -66,11 +58,11 @@ describe("agent workspace Git status route", () => { expect(response.status).toBe(200); expect(response.headers.get("Cache-Control")).toBe("no-store"); - expect(mocks.targetForStoredHost).toHaveBeenCalled(); - expect(mocks.inspectAgentWorkspaceGitStatus).toHaveBeenCalledWith( - { connectorId: "connector", transport: "local" }, - "/srv/project", - ); + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "git_status", + target: { transport: "local", shellMode: "interactive" }, + path: "/srv/project", + }); await expect(response.json()).resolves.toMatchObject({ status: { branch: "feature/status", @@ -90,11 +82,11 @@ describe("agent workspace Git status route", () => { mocks.getOwnedAgentWorkspace.mockResolvedValueOnce(null); expect((await GET(request, context)).status).toBe(404); - expect(mocks.inspectAgentWorkspaceGitStatus).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); it("returns a useful connector error without caching it", async () => { - mocks.inspectAgentWorkspaceGitStatus.mockRejectedValue( + mocks.daemonRequest.mockRejectedValue( new Error("Git is not installed on the selected machine."), ); diff --git a/apps/web/app/api/agent-workspaces/[id]/git-status/route.ts b/apps/web/app/api/agent-workspaces/[id]/git-status/route.ts index ab4ffe40..fe6fd4cd 100644 --- a/apps/web/app/api/agent-workspaces/[id]/git-status/route.ts +++ b/apps/web/app/api/agent-workspaces/[id]/git-status/route.ts @@ -3,8 +3,8 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { inspectAgentWorkspaceGitStatus } from "@/lib/agents/runtime/git"; -import { targetForStoredHost } from "@/lib/agents/runtime/target"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { daemonTarget } from "@/lib/agents/connector/descriptors"; import { getOwnedAgentWorkspace } from "@/lib/db/agentConnections"; export const maxDuration = 30; @@ -25,9 +25,13 @@ export async function GET( if (!owned) return new Response("Not found", { status: 404 }); try { - const status = await inspectAgentWorkspaceGitStatus( - targetForStoredHost(owned.host, owned.connection.shellMode), - owned.workspace.path, + const status = await hostConnectorBroker.request( + owned.host.connectorId, + { + type: "git_status", + target: daemonTarget(owned.host, owned.connection.shellMode), + path: owned.workspace.path, + }, ); return Response.json( { status }, diff --git a/apps/web/app/api/agent-workspaces/[id]/route.ts b/apps/web/app/api/agent-workspaces/[id]/route.ts index 1788ef43..5bec8076 100644 --- a/apps/web/app/api/agent-workspaces/[id]/route.ts +++ b/apps/web/app/api/agent-workspaces/[id]/route.ts @@ -3,10 +3,12 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; -import { targetForStoredHost } from "@/lib/agents/runtime/target"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { + daemonWorkspace, + parseProviderSessionMetadata, +} from "@/lib/agents/connector/descriptors"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; import { deleteAgentWorkspace, getOwnedAgentWorkspace, @@ -32,14 +34,17 @@ export async function POST( if (!isAgentProviderId(owned.connection.provider)) { throw new Error("This coding-agent provider is not supported."); } - const sessions = await agentProviderAdapter( - owned.connection.provider, - ).listWorkspaceSessions( - targetForStoredHost(owned.host, owned.connection.shellMode), - owned.connection.executable, - owned.workspace.path, + const sessions = await hostConnectorBroker.request( + owned.host.connectorId, + { + type: "list_sessions", + workspace: daemonWorkspace(owned), + }, + ); + const rows = syncAgentWorkspaceSessions( + id, + sessions.map(parseProviderSessionMetadata), ); - const rows = syncAgentWorkspaceSessions(id, sessions); return Response.json({ sessions: rows.map((row) => ({ id: row.id, @@ -49,10 +54,7 @@ export async function POST( messageCount: row.messageCount, createdAt: row.providerCreatedAt?.getTime() ?? null, modifiedAt: row.providerModifiedAt?.getTime() ?? null, - runtimeStatus: agentRuntimeRegistry.runtimeStatusForSession( - row.id, - session.user.id, - ), + runtimeStatus: hostConnectorBroker.runtimeStatusForSession(row.id), })), }); } catch (error) { @@ -81,7 +83,14 @@ export async function DELETE( return Response.json({ error: accessError }, { status: 403 }); } const { id } = await params; - await agentRuntimeRegistry.stopWorkspace(id, session.user.id); + const owned = await getOwnedAgentWorkspace(id, session.user.id); + if (!owned) return new Response("Not found", { status: 404 }); + await hostConnectorBroker + .request(owned.host.connectorId, { + type: "stop_workspace", + workspaceId: id, + }) + .catch(() => {}); const deleted = await deleteAgentWorkspace(id, session.user.id); return deleted ? new Response(null, { status: 204 }) diff --git a/apps/web/app/api/agent-workspaces/[id]/sessions/route.ts b/apps/web/app/api/agent-workspaces/[id]/sessions/route.ts index 789ff32b..c8935a1f 100644 --- a/apps/web/app/api/agent-workspaces/[id]/sessions/route.ts +++ b/apps/web/app/api/agent-workspaces/[id]/sessions/route.ts @@ -3,9 +3,16 @@ import { connectionErrorMessage, storedConnectionAccessError, } from "@/lib/agents/access"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; -import { getOwnedAgentWorkspace } from "@/lib/db/agentConnections"; -import { isAgentProviderId } from "@/lib/agents/catalog"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { + daemonWorkspace, + parseProviderSessionMetadata, +} from "@/lib/agents/connector/descriptors"; +import { + getOwnedAgentWorkspace, + upsertAgentSession, +} from "@/lib/db/agentConnections"; +import { isAgentProviderId } from "@overtchat/agent-bridge"; export const maxDuration = 150; @@ -24,12 +31,25 @@ export async function POST( if (!owned) return new Response("Not found", { status: 404 }); try { - const created = await agentRuntimeRegistry.create(owned); + const sessionId = crypto.randomUUID(); + const created = await hostConnectorBroker.request<{ + session: unknown; + snapshot: unknown; + }>(owned.host.connectorId, { + type: "create_session", + sessionId, + workspace: daemonWorkspace(owned), + }); + const row = await upsertAgentSession( + owned.workspace.id, + parseProviderSessionMetadata(created.session), + sessionId, + ); return Response.json( { session: { - id: created.sessionId, - snapshot: created.runtime.snapshot(), + id: row.id, + snapshot: created.snapshot, }, }, { status: 201 }, diff --git a/apps/web/app/api/host-connectors/channel/route.test.ts b/apps/web/app/api/host-connectors/channel/route.test.ts new file mode 100644 index 00000000..a9e0c38c --- /dev/null +++ b/apps/web/app/api/host-connectors/channel/route.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + HOST_CONNECTOR_PROTOCOL_VERSION, + HOST_CONNECTOR_RELEASE_VERSION, +} from "@overtchat/agent-bridge"; + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + register: vi.fn(), + unregister: vi.fn(), + touch: vi.fn(), + listActiveSessions: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/agents/connector/auth", () => ({ + authenticateHostConnector: mocks.authenticate, +})); +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { register: mocks.register }, +})); +vi.mock("@/lib/db/hostConnectors", () => ({ + touchHostConnector: mocks.touch, +})); +vi.mock("@/lib/db/agentConnections", () => ({ + listActiveAgentSessionIds: mocks.listActiveSessions, +})); + +import { GET } from "./route"; + +function request(version: string, protocol: number): Request { + return new Request("http://server.test/api/host-connectors/channel", { + headers: { + "X-OvertChat-Connector-Version": version, + "X-OvertChat-Connector-Protocol": String(protocol), + }, + }); +} + +describe("Host Connector command channel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticate.mockReturnValue({ id: "connector" }); + mocks.register.mockImplementation( + ( + _connectorId: string, + _activeSessionIds: string[], + send: (value: unknown) => void, + ) => { + send({ + type: "sync", + connectionEpoch: "connection", + activeSessionIds: ["session"], + }); + return mocks.unregister; + }, + ); + mocks.listActiveSessions.mockResolvedValue(["session"]); + }); + + it("opens only for the exact connector release and protocol", async () => { + const response = await GET( + request(HOST_CONNECTOR_RELEASE_VERSION, HOST_CONNECTOR_PROTOCOL_VERSION), + ); + + expect(response.status).toBe(200); + const reader = response.body!.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toContain('"type":"sync"'); + expect(mocks.register).toHaveBeenCalledWith( + "connector", + ["session"], + expect.any(Function), + ); + expect(mocks.touch).toHaveBeenCalledWith( + "connector", + HOST_CONNECTOR_RELEASE_VERSION, + ); + await reader.cancel(); + expect(mocks.unregister).toHaveBeenCalled(); + }); + + it("rejects any other release or protocol without opening a channel", async () => { + const wrongRelease = await GET( + request("9.9.9", HOST_CONNECTOR_PROTOCOL_VERSION), + ); + expect(wrongRelease.status).toBe(409); + + const wrongProtocol = await GET( + request( + HOST_CONNECTOR_RELEASE_VERSION, + HOST_CONNECTOR_PROTOCOL_VERSION + 1, + ), + ); + expect(wrongProtocol.status).toBe(409); + expect(mocks.register).not.toHaveBeenCalled(); + }); + + it("requires a connector credential", async () => { + mocks.authenticate.mockReturnValue(null); + + const response = await GET( + request(HOST_CONNECTOR_RELEASE_VERSION, HOST_CONNECTOR_PROTOCOL_VERSION), + ); + + expect(response.status).toBe(401); + expect(mocks.register).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/host-connectors/channel/route.ts b/apps/web/app/api/host-connectors/channel/route.ts index 1f0376d0..56ae9c55 100644 --- a/apps/web/app/api/host-connectors/channel/route.ts +++ b/apps/web/app/api/host-connectors/channel/route.ts @@ -1,11 +1,12 @@ import { - HOST_CONNECTOR_PROTOCOL_MIN_VERSION, + HOST_CONNECTOR_RELEASE_VERSION, HOST_CONNECTOR_PROTOCOL_VERSION, isHostConnectorProtocolVersion, type HostConnectorCommand, } from "@overtchat/agent-bridge"; import { authenticateHostConnector } from "@/lib/agents/connector/auth"; import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { listActiveAgentSessionIds } from "@/lib/db/agentConnections"; import { touchHostConnector } from "@/lib/db/hostConnectors"; export const dynamic = "force-dynamic"; @@ -17,18 +18,25 @@ export async function GET(request: Request) { const protocol = Number( request.headers.get("x-overtchat-connector-protocol"), ); - if (!isHostConnectorProtocolVersion(protocol)) { + const connectorVersion = request.headers.get( + "x-overtchat-connector-version", + ); + if ( + !isHostConnectorProtocolVersion(protocol) || + connectorVersion !== HOST_CONNECTOR_RELEASE_VERSION + ) { return Response.json( { - error: `Connector protocol ${protocol || "unknown"} is incompatible with server protocols ${HOST_CONNECTOR_PROTOCOL_MIN_VERSION}-${HOST_CONNECTOR_PROTOCOL_VERSION}.`, + error: `Connector ${connectorVersion ?? "unknown"} does not match this server. Reinstall OvertChat Connector ${HOST_CONNECTOR_RELEASE_VERSION} (protocol ${HOST_CONNECTOR_PROTOCOL_VERSION}).`, }, { status: 409 }, ); } touchHostConnector( connector.id, - request.headers.get("x-overtchat-connector-version"), + connectorVersion, ); + const activeSessionIds = await listActiveAgentSessionIds(connector.id); let close = () => {}; const body = new ReadableStream({ @@ -41,6 +49,7 @@ export async function GET(request: Request) { }; const unregister = hostConnectorBroker.register( connector.id, + activeSessionIds, send, ); const keepAlive = setInterval(() => { diff --git a/apps/web/app/api/host-connectors/events/route.test.ts b/apps/web/app/api/host-connectors/events/route.test.ts index c7b0b880..b2ca4252 100644 --- a/apps/web/app/api/host-connectors/events/route.test.ts +++ b/apps/web/app/api/host-connectors/events/route.test.ts @@ -2,11 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { HOST_CONNECTOR_EVENT_BATCH_LIMIT, HOST_CONNECTOR_PROTOCOL_VERSION, + HOST_CONNECTOR_RELEASE_VERSION, } from "@overtchat/agent-bridge"; const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), - accept: vi.fn(), + acceptBatch: vi.fn(), touch: vi.fn(), })); @@ -16,7 +17,7 @@ vi.mock("@/lib/agents/connector/auth", () => ({ })); vi.mock("@/lib/agents/connector/broker", () => ({ hostConnectorBroker: { - accept: mocks.accept, + acceptBatch: mocks.acceptBatch, }, })); vi.mock("@/lib/db/hostConnectors", () => ({ @@ -25,10 +26,19 @@ vi.mock("@/lib/db/hostConnectors", () => ({ import { POST } from "./route"; -function request(body: unknown): Request { +function request( + body: unknown, + version = HOST_CONNECTOR_RELEASE_VERSION, +): Request { return new Request("http://server.test/api/host-connectors/events", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-OvertChat-Connector-Version": version, + "X-OvertChat-Connector-Protocol": String( + HOST_CONNECTOR_PROTOCOL_VERSION, + ), + }, body: JSON.stringify(body), }); } @@ -37,23 +47,40 @@ describe("Host Connector event route", () => { beforeEach(() => { vi.clearAllMocks(); mocks.authenticate.mockReturnValue({ id: "connector" }); + mocks.acceptBatch.mockReturnValue({ + connectorEpoch: "daemon", + acknowledgedSequence: 1, + }); }); it("accepts validated connector events", async () => { const event = { - type: "stdout", - processId: "process", - data: Buffer.from("hello").toString("base64"), + sequence: 1, + payload: { + type: "response", + requestId: "request", + success: true, + data: "hello", + }, }; const response = await POST( request({ protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION, + connectorEpoch: "daemon", events: [event], }), ); - expect(response.status).toBe(204); - expect(mocks.accept).toHaveBeenCalledWith("connector", event); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + connectorEpoch: "daemon", + acknowledgedSequence: 1, + }); + expect(mocks.acceptBatch).toHaveBeenCalledWith( + "connector", + "daemon", + [event], + ); expect(mocks.touch).toHaveBeenCalledWith("connector"); }); @@ -61,43 +88,77 @@ describe("Host Connector event route", () => { const response = await POST( request({ protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION, + connectorEpoch: "daemon", events: [null], }), ); expect(response.status).toBe(400); - expect(mocks.accept).not.toHaveBeenCalled(); + expect(mocks.acceptBatch).not.toHaveBeenCalled(); }); it("rejects event batches above the shared connector limit", async () => { const response = await POST( request({ protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION, + connectorEpoch: "daemon", events: Array.from( { length: HOST_CONNECTOR_EVENT_BATCH_LIMIT + 1 }, (_, index) => ({ - type: "stdout", - processId: "process", - data: String(index), + sequence: index + 1, + payload: { + type: "response", + requestId: String(index), + success: true, + data: index, + }, }), ), }), ); expect(response.status).toBe(400); - expect(mocks.accept).not.toHaveBeenCalled(); + expect(mocks.acceptBatch).not.toHaveBeenCalled(); }); it("rejects unsupported connector protocol versions", async () => { - const response = await POST( - request({ + const response = await POST(new Request( + "http://server.test/api/host-connectors/events", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-OvertChat-Connector-Version": HOST_CONNECTOR_RELEASE_VERSION, + "X-OvertChat-Connector-Protocol": String( + HOST_CONNECTOR_PROTOCOL_VERSION + 1, + ), + }, + body: JSON.stringify({ protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION + 1, + connectorEpoch: "daemon", events: [], - }), + }), + }, + )); + + expect(response.status).toBe(409); + expect(mocks.acceptBatch).not.toHaveBeenCalled(); + }); + + it("rejects a different connector release", async () => { + const response = await POST( + request( + { + protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION, + connectorEpoch: "daemon", + events: [], + }, + "9.9.9", + ), ); - expect(response.status).toBe(400); - expect(mocks.accept).not.toHaveBeenCalled(); + expect(response.status).toBe(409); + expect(mocks.acceptBatch).not.toHaveBeenCalled(); }); it("requires a connector credential", async () => { @@ -106,6 +167,7 @@ describe("Host Connector event route", () => { const response = await POST( request({ protocolVersion: HOST_CONNECTOR_PROTOCOL_VERSION, + connectorEpoch: "daemon", events: [], }), ); diff --git a/apps/web/app/api/host-connectors/events/route.ts b/apps/web/app/api/host-connectors/events/route.ts index 5a833a5c..074d8cf7 100644 --- a/apps/web/app/api/host-connectors/events/route.ts +++ b/apps/web/app/api/host-connectors/events/route.ts @@ -1,5 +1,6 @@ import { HOST_CONNECTOR_EVENT_BATCH_LIMIT, + HOST_CONNECTOR_RELEASE_VERSION, isHostConnectorEvent, isHostConnectorProtocolVersion, type HostConnectorEventBatch, @@ -11,21 +12,37 @@ import { touchHostConnector } from "@/lib/db/hostConnectors"; export async function POST(request: Request) { const connector = authenticateHostConnector(request); if (!connector) return new Response("Unauthorized", { status: 401 }); + if ( + request.headers.get("x-overtchat-connector-version") !== + HOST_CONNECTOR_RELEASE_VERSION || + !isHostConnectorProtocolVersion( + Number(request.headers.get("x-overtchat-connector-protocol")), + ) + ) { + return Response.json( + { error: "The Host Connector release does not match this server." }, + { status: 409 }, + ); + } const batch = (await request.json().catch(() => null)) as | HostConnectorEventBatch | null; if ( !batch || !isHostConnectorProtocolVersion(batch.protocolVersion) || + typeof batch.connectorEpoch !== "string" || + batch.connectorEpoch.length === 0 || !Array.isArray(batch.events) || batch.events.length > HOST_CONNECTOR_EVENT_BATCH_LIMIT || !batch.events.every(isHostConnectorEvent) ) { return Response.json({ error: "Invalid connector event batch." }, { status: 400 }); } - for (const event of batch.events) { - hostConnectorBroker.accept(connector.id, event); - } + const ack = hostConnectorBroker.acceptBatch( + connector.id, + batch.connectorEpoch, + batch.events, + ); touchHostConnector(connector.id); - return new Response(null, { status: 204 }); + return Response.json(ack); } diff --git a/apps/web/app/api/host-connectors/route.test.ts b/apps/web/app/api/host-connectors/route.test.ts index cca39741..243ded7f 100644 --- a/apps/web/app/api/host-connectors/route.test.ts +++ b/apps/web/app/api/host-connectors/route.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), isOnline: vi.fn(), - stopUser: vi.fn(), + daemonRequest: vi.fn(), createPairing: vi.fn(), deleteConnector: vi.fn(), getOwnedConnector: vi.fn(), @@ -15,10 +15,10 @@ vi.mock("@/lib/auth/server", () => ({ auth: { api: { getSession: mocks.getSession } }, })); vi.mock("@/lib/agents/connector/broker", () => ({ - hostConnectorBroker: { isOnline: mocks.isOnline }, -})); -vi.mock("@/lib/agents/runtime/registry", () => ({ - agentRuntimeRegistry: { stopUser: mocks.stopUser }, + hostConnectorBroker: { + isOnline: mocks.isOnline, + request: mocks.daemonRequest, + }, })); vi.mock("@/lib/db/hostConnectors", () => ({ createHostConnectorPairing: mocks.createPairing, @@ -54,6 +54,7 @@ describe("Host Connectors route", () => { }); mocks.getOwnedConnector.mockReturnValue({ id: "connector" }); mocks.deleteConnector.mockReturnValue(true); + mocks.daemonRequest.mockResolvedValue({ stopped: true }); }); it("keeps connector administration restricted to administrators", async () => { @@ -68,7 +69,7 @@ describe("Host Connectors route", () => { ).toBe(403); expect(mocks.listConnectors).not.toHaveBeenCalled(); expect(mocks.createPairing).not.toHaveBeenCalled(); - expect(mocks.stopUser).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); it("returns a one-command local pairing flow", async () => { @@ -79,7 +80,7 @@ describe("Host Connectors route", () => { pairCode: "ocp_pair.secret", expiresAt: 10_000, command: - "curl --proto '=https' --tlsv1.2 -fsSL https://overtchat.com/install/connector/0.1.0 | sh -s -- --server 'http://127.0.0.1:9000' --pair-code 'ocp_pair.secret'", + "curl --proto '=https' --tlsv1.2 -fsSL https://overtchat.com/install/connector/0.2.0 | sh -s -- --server 'http://127.0.0.1:9000' --pair-code 'ocp_pair.secret'", }); expect(mocks.createPairing).toHaveBeenCalledWith("admin"); }); @@ -94,12 +95,14 @@ describe("Host Connectors route", () => { "connector", "admin", ); - expect(mocks.stopUser).toHaveBeenCalledWith("admin"); + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "stop_all", + }); expect(mocks.deleteConnector).toHaveBeenCalledWith( "connector", "admin", ); - expect(mocks.stopUser.mock.invocationCallOrder[0]).toBeLessThan( + expect(mocks.daemonRequest.mock.invocationCallOrder[0]).toBeLessThan( mocks.deleteConnector.mock.invocationCallOrder[0], ); }); @@ -112,7 +115,7 @@ describe("Host Connectors route", () => { ); expect(response.status).toBe(404); - expect(mocks.stopUser).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); expect(mocks.deleteConnector).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/host-connectors/route.ts b/apps/web/app/api/host-connectors/route.ts index d7610ebb..be446ce2 100644 --- a/apps/web/app/api/host-connectors/route.ts +++ b/apps/web/app/api/host-connectors/route.ts @@ -4,7 +4,6 @@ import { } from "@overtchat/agent-bridge"; import { auth } from "@/lib/auth/server"; import { hostConnectorBroker } from "@/lib/agents/connector/broker"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; import { createHostConnectorPairing, deleteHostConnector, @@ -83,7 +82,7 @@ export async function DELETE(request: Request) { if (!getOwnedHostConnector(id, result.user.id)) { return new Response("Not found", { status: 404 }); } - await agentRuntimeRegistry.stopUser(result.user.id); + await hostConnectorBroker.request(id, { type: "stop_all" }).catch(() => {}); return deleteHostConnector(id, result.user.id) ? new Response(null, { status: 204 }) : new Response("Not found", { status: 404 }); diff --git a/apps/web/app/api/host-connectors/uploads/[id]/route.ts b/apps/web/app/api/host-connectors/uploads/[id]/route.ts new file mode 100644 index 00000000..713a1451 --- /dev/null +++ b/apps/web/app/api/host-connectors/uploads/[id]/route.ts @@ -0,0 +1,27 @@ +import fs from "node:fs/promises"; +import { authenticateHostConnector } from "@/lib/agents/connector/auth"; +import { getUpload, uploadPath } from "@/lib/db/uploads"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const connector = authenticateHostConnector(request); + if (!connector) return new Response("Unauthorized", { status: 401 }); + const { id } = await params; + const upload = await getUpload(id, connector.userId); + if (!upload || upload.category !== "image") { + return new Response("Not found", { status: 404 }); + } + try { + const bytes = await fs.readFile(uploadPath(id)); + return new Response(new Uint8Array(bytes), { + headers: { + "Content-Type": upload.mediaType, + "Cache-Control": "private, no-store", + }, + }); + } catch { + return new Response("Not found", { status: 404 }); + } +} diff --git a/apps/web/app/api/users/[id]/role/route.test.ts b/apps/web/app/api/users/[id]/role/route.test.ts index 9ca4e5a9..1d7f623c 100644 --- a/apps/web/app/api/users/[id]/role/route.test.ts +++ b/apps/web/app/api/users/[id]/role/route.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), changeUserRole: vi.fn(), - stopUser: vi.fn(), + daemonRequest: vi.fn(), + listConnectors: vi.fn(), })); vi.mock("server-only", () => ({})); @@ -13,8 +14,11 @@ vi.mock("@/lib/auth/server", () => ({ vi.mock("@/lib/db/users", () => ({ changeUserRole: mocks.changeUserRole, })); -vi.mock("@/lib/agents/runtime/registry", () => ({ - agentRuntimeRegistry: { stopUser: mocks.stopUser }, +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { request: mocks.daemonRequest }, +})); +vi.mock("@/lib/db/hostConnectors", () => ({ + listHostConnectors: mocks.listConnectors, })); import { PATCH } from "./route"; @@ -44,6 +48,7 @@ describe("user role route", () => { role: "user", }, }); + mocks.listConnectors.mockReturnValue([{ id: "connector" }]); }); it("requires an administrator", async () => { @@ -64,7 +69,9 @@ describe("user role route", () => { "target", "user", ); - expect(mocks.stopUser).toHaveBeenCalledWith("target"); + expect(mocks.daemonRequest).toHaveBeenCalledWith("connector", { + type: "stop_all", + }); }); it("surfaces role-change safeguards", async () => { @@ -76,6 +83,6 @@ describe("user role route", () => { await expect(response.json()).resolves.toEqual({ error: "At least one administrator is required.", }); - expect(mocks.stopUser).not.toHaveBeenCalled(); + expect(mocks.daemonRequest).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/users/[id]/role/route.ts b/apps/web/app/api/users/[id]/role/route.ts index 90c61b6f..b75ca5e0 100644 --- a/apps/web/app/api/users/[id]/role/route.ts +++ b/apps/web/app/api/users/[id]/role/route.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { auth } from "@/lib/auth/server"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; +import { hostConnectorBroker } from "@/lib/agents/connector/broker"; +import { listHostConnectors } from "@/lib/db/hostConnectors"; import { changeUserRole } from "@/lib/db/users"; const bodySchema = z.object({ @@ -41,7 +42,11 @@ export async function PATCH( } if (result.status === "updated" && parsed.data.role === "user") { - await agentRuntimeRegistry.stopUser(id); + await Promise.allSettled( + listHostConnectors(id).map((connector) => + hostConnectorBroker.request(connector.id, { type: "stop_all" }), + ), + ); } return Response.json({ user: result.user }); } diff --git a/apps/web/components/SidebarConnections.tsx b/apps/web/components/SidebarConnections.tsx index 138aba05..b08ba711 100644 --- a/apps/web/components/SidebarConnections.tsx +++ b/apps/web/components/SidebarConnections.tsx @@ -19,8 +19,8 @@ import type { AgentSessionListItem, AgentWorkspaceGitStatus, AgentWorkspaceListItem, -} from "@/lib/agents/types"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; +} from "@overtchat/agent-bridge"; +import { agentProviderMetadata } from "@overtchat/agent-bridge"; import { AGENT_SESSION_PREVIEW_COUNT, agentConnectionHasRunningSession, diff --git a/apps/web/components/agents/AgentComposer.tsx b/apps/web/components/agents/AgentComposer.tsx index 3775ed47..744331be 100644 --- a/apps/web/components/agents/AgentComposer.tsx +++ b/apps/web/components/agents/AgentComposer.tsx @@ -20,19 +20,19 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { agentSlashCommandQuery } from "@/lib/agents/runtime/commands"; +import { agentSlashCommandQuery } from "@overtchat/agent-bridge"; import type { AgentPromptImage, AgentQueuedMessage, AgentSessionStats, AgentSlashCommand, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { AGENT_IMAGE_MEDIA_TYPES, MAX_AGENT_IMAGES, MAX_AGENT_IMAGE_BYTES, MAX_AGENT_IMAGE_TOTAL_BYTES, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { getDataTransferFiles } from "@/lib/chat/attachments"; import { motionClasses } from "@/lib/motion"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/agents/AgentComposerControls.tsx b/apps/web/components/agents/AgentComposerControls.tsx index 1f7918f1..cd379cb2 100644 --- a/apps/web/components/agents/AgentComposerControls.tsx +++ b/apps/web/components/agents/AgentComposerControls.tsx @@ -18,7 +18,7 @@ import type { AgentCollaborationMode, AgentModel, AgentThinkingLevel, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { motionClasses } from "@/lib/motion"; import { modelIconForModel } from "@/lib/providers/catalog"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/agents/AgentSessionDialogs.tsx b/apps/web/components/agents/AgentSessionDialogs.tsx index d7caa30a..ec597230 100644 --- a/apps/web/components/agents/AgentSessionDialogs.tsx +++ b/apps/web/components/agents/AgentSessionDialogs.tsx @@ -13,7 +13,7 @@ import type { AgentInteractionValue, AgentRuntimeSnapshot, AgentUsageSnapshot, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { motionClasses } from "@/lib/motion"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/agents/AgentSessionHeader.tsx b/apps/web/components/agents/AgentSessionHeader.tsx index 7fbfc553..e56b6b20 100644 --- a/apps/web/components/agents/AgentSessionHeader.tsx +++ b/apps/web/components/agents/AgentSessionHeader.tsx @@ -17,7 +17,7 @@ import { SidebarToggle } from "@/components/SidebarToggle"; import type { AgentSessionStats, AgentWorkspaceGitStatus, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { motionClasses } from "@/lib/motion"; import { useAgentWorkspaceGitStatus } from "@/lib/queries/agentWorkspaces"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/agents/AgentSessionView.tsx b/apps/web/components/agents/AgentSessionView.tsx index f9fe4f41..2546a986 100644 --- a/apps/web/components/agents/AgentSessionView.tsx +++ b/apps/web/components/agents/AgentSessionView.tsx @@ -15,7 +15,7 @@ import { import { Button } from "@/components/ui/button"; import { SidebarToggle } from "@/components/SidebarToggle"; import { toast } from "@/components/ui/toast"; -import { AGENT_GOAL_STATUSES } from "@/lib/agents/types"; +import { AGENT_GOAL_STATUSES } from "@overtchat/agent-bridge"; import type { AgentCollaborationMode, AgentGoal, @@ -25,12 +25,12 @@ import type { AgentSessionCommand, AgentThinkingLevel, AgentUsageSnapshot, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { buildAgentPromptCommand, normalizeAgentSessionCommand, -} from "@/lib/agents/runtime/commands"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; + agentProviderMetadata, +} from "@overtchat/agent-bridge"; import { useAgentSession, useAgentSessionCommand, diff --git a/apps/web/e2e/agent-runtime.spec.ts b/apps/web/e2e/agent-runtime.spec.ts index 76e56b0b..29a26b9d 100644 --- a/apps/web/e2e/agent-runtime.spec.ts +++ b/apps/web/e2e/agent-runtime.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import type { AgentConnectionListItem, AgentRuntimeSnapshot, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { openE2eDatabase, resetE2eDatabase, @@ -484,6 +484,7 @@ test("shows durable turn activity without changing completed tool status", async ); await page.addInitScript(() => { type RuntimeEnvelope = { + epoch: string; sequence: number; type: "runtime_event" | "snapshot"; data: Record; @@ -556,7 +557,8 @@ test("shows durable turn activity without changing completed tool status", async } ).__agentRuntimeControls; controls.emit({ - sequence: 0, + epoch: "runtime-test", + sequence: 1, type: "snapshot", data: initialSnapshot, }); @@ -799,7 +801,8 @@ test("shows durable turn activity without changing completed tool status", async } ).__agentRuntimeControls; controls.emit({ - sequence: 1, + epoch: "runtime-test", + sequence: 2, type: "runtime_event", data: { type: "compaction_start", reason: "auto" }, }); @@ -816,7 +819,8 @@ test("shows durable turn activity without changing completed tool status", async } ).__agentRuntimeControls; controls.emit({ - sequence: 2, + epoch: "runtime-test", + sequence: 3, type: "runtime_event", data: { type: "compaction_end", reason: "auto" }, }); @@ -867,7 +871,8 @@ test("shows durable turn activity without changing completed tool status", async ).__agentRuntimeControls; controls.reconnect(); controls.emit({ - sequence: 3, + epoch: "runtime-test", + sequence: 4, type: "runtime_event", data: { type: "overtchat_status", status: "idle", startedAt: null }, }); @@ -932,7 +937,8 @@ test("shows durable turn activity without changing completed tool status", async } ).__agentRuntimeControls; controls.emit({ - sequence: 4, + epoch: "runtime-test", + sequence: 5, type: "runtime_event", data: { type: "config_update", @@ -1116,7 +1122,8 @@ test("shows durable turn activity without changing completed tool status", async } ).__agentRuntimeControls; controls.emit({ - sequence: 6, + epoch: "runtime-test", + sequence: 7, type: "snapshot", data: { ...initialSnapshot, diff --git a/apps/web/e2e/connections.spec.ts b/apps/web/e2e/connections.spec.ts index 8223f969..3ae0d586 100644 --- a/apps/web/e2e/connections.spec.ts +++ b/apps/web/e2e/connections.spec.ts @@ -86,7 +86,7 @@ async function startHostConnector( ); await page.keyboard.press("Escape"); const command = await page.getByLabel("Host Connector install command").textContent(); - expect(command).toContain("https://overtchat.com/install/connector/0.1.0"); + expect(command).toContain("https://overtchat.com/install/connector/0.2.0"); expect(command).toContain("--server 'http://127.0.0.1:4718'"); const pairCode = /--pair-code '([^']+)'/u.exec(command ?? "")?.[1]; if (!pairCode) throw new Error("The Host Connector pairing code was missing."); diff --git a/apps/web/lib/agents/access.ts b/apps/web/lib/agents/access.ts index 41881722..6be62057 100644 --- a/apps/web/lib/agents/access.ts +++ b/apps/web/lib/agents/access.ts @@ -1,5 +1,5 @@ -import type { AgentProviderId } from "@/lib/agents/types"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; +import type { AgentProviderId } from "@overtchat/agent-bridge"; +import { agentProviderMetadata } from "@overtchat/agent-bridge"; export function connectionAccessError( role: string | null | undefined, diff --git a/apps/web/lib/agents/connector/broker.test.ts b/apps/web/lib/agents/connector/broker.test.ts index 51720b0c..3fa31ff1 100644 --- a/apps/web/lib/agents/connector/broker.test.ts +++ b/apps/web/lib/agents/connector/broker.test.ts @@ -1,268 +1,149 @@ import { describe, expect, it, vi } from "vitest"; -import type { HostConnectorCommand } from "@overtchat/agent-bridge"; +import type { + AgentDaemonSessionDescriptor, + HostConnectorCommand, + HostConnectorEvent, +} from "@overtchat/agent-bridge"; vi.mock("server-only", () => ({})); +vi.mock("@/lib/db/agentConnections", () => ({ + updateAgentSessionMetadata: vi.fn(), +})); import { HostConnectorBroker } from "./broker"; -describe("Host Connector broker", () => { - it("bridges a virtual process from spawn through stdout and exit", async () => { - const broker = new HostConnectorBroker(); +const session: AgentDaemonSessionDescriptor = { + connectionId: "connection", + workspaceId: "workspace", + provider: "codex", + target: { transport: "local", shellMode: "interactive" }, + executable: "codex", + cwd: "/workspace", + sessionId: "session", + providerSessionId: "thread", + providerSessionPath: "/thread.jsonl", +}; + +function response( + sequence: number, + requestId: string, + data: unknown, +): HostConnectorEvent { + return { + sequence, + payload: { type: "response", requestId, success: true, data }, + }; +} + +describe("host connector daemon broker", () => { + it("starts an exact connection epoch and resolves agent-level requests", async () => { const commands: HostConnectorCommand[] = []; - broker.register("connector", (command) => commands.push(command)); - - const processHandle = broker.spawn( - "connector", - { transport: "ssh", alias: "macbook" }, - { - command: "omp", - args: ["--mode", "rpc"], - shellMode: "interactive", - }, - ); - const spawn = commands.find( - ( - command, - ): command is Extract => - command.type === "spawn", - ); - expect(spawn).toMatchObject({ - target: { transport: "ssh", alias: "macbook" }, - launch: { - command: "omp", - args: ["--mode", "rpc"], - shellMode: "interactive", - }, - }); - - let stdout = ""; - processHandle.stdout.on("data", (chunk) => { - stdout += chunk.toString(); - }); - processHandle.stdin.write("hello"); - expect(commands.at(-1)).toMatchObject({ - type: "stdin", - processId: spawn!.processId, - data: Buffer.from("hello").toString("base64"), - }); + const broker = new HostConnectorBroker(); + broker.register("connector", ["session"], (command) => commands.push(command)); - broker.accept("connector", { - type: "stdout", - processId: spawn!.processId, - data: Buffer.from("world").toString("base64"), + expect(commands[0]).toMatchObject({ + type: "sync", + activeSessionIds: ["session"], }); - broker.accept("connector", { - type: "exit", - processId: spawn!.processId, - code: 0, - signal: null, + const pending = broker.request("connector", { type: "open_session", session }); + const request = commands.at(-1); + expect(request).toMatchObject({ + type: "request", + request: { type: "open_session" }, }); + if (request?.type !== "request") throw new Error("missing request"); - await expect(processHandle.exit).resolves.toEqual({ - code: 0, - signal: null, - }); - expect(stdout).toBe("world"); + expect( + broker.acceptBatch("connector", "daemon-epoch", [ + response(1, request.requestId, { snapshot: "ready" }), + ]), + ).toEqual({ connectorEpoch: "daemon-epoch", acknowledgedSequence: 1 }); + await expect(pending).resolves.toEqual({ snapshot: "ready" }); }); - it("chunks large stdin writes before sending them to the connector", async () => { - const broker = new HostConnectorBroker(); + it("acknowledges duplicate transport events without applying them twice", async () => { const commands: HostConnectorCommand[] = []; - broker.register("connector", (command) => commands.push(command)); - const processHandle = broker.spawn( - "connector", - { transport: "local" }, - { command: "pi", shellMode: "interactive" }, - ); - const payload = Buffer.alloc(64 * 1024 + 17, 7); + const broker = new HostConnectorBroker(); + broker.register("connector", [], (command) => commands.push(command)); + const pending = broker.request("connector", { type: "list_ssh_hosts" }); + const request = commands.at(-1); + if (request?.type !== "request") throw new Error("missing request"); + const event = response(1, request.requestId, []); - await new Promise((resolve, reject) => { - processHandle.stdin.end(payload, (error?: Error | null) => { - if (error) reject(error); - else resolve(); - }); - }); + broker.acceptBatch("connector", "daemon-epoch", [event]); + broker.acceptBatch("connector", "daemon-epoch", [event]); - const stdinCommands = commands.filter( - ( - command, - ): command is Extract => - command.type === "stdin", - ); - expect(stdinCommands).toHaveLength(2); + await expect(pending).resolves.toEqual([]); expect( - Buffer.concat( - stdinCommands.map((command) => Buffer.from(command.data, "base64")), - ), - ).toEqual(payload); - expect(commands.at(-1)).toMatchObject({ type: "stdin_end" }); + broker.acceptBatch("connector", "daemon-epoch", [event]), + ).toEqual({ connectorEpoch: "daemon-epoch", acknowledgedSequence: 1 }); }); - it("synchronizes active process IDs when a channel reconnects", () => { + it("tracks overlapping connector epochs independently", () => { const broker = new HostConnectorBroker(); - const first: HostConnectorCommand[] = []; - const unregister = broker.register("connector", (command) => - first.push(command), - ); - broker.spawn( - "connector", - { transport: "local" }, - { command: "omp", shellMode: "interactive" }, - ); - const processId = ( - first.find( - ( - command, - ): command is Extract => - command.type === "spawn", - )! - ).processId; - unregister(); - - const second: HostConnectorCommand[] = []; - broker.register("connector", (command) => second.push(command)); - - expect(second[0]).toEqual({ type: "sync", processIds: [processId] }); - }); - - it("fails live processes and requests when a connector stays offline", async () => { - vi.useFakeTimers(); - try { - const broker = new HostConnectorBroker(1_000); - const commands: HostConnectorCommand[] = []; - const unregister = broker.register("connector", (command) => - commands.push(command), - ); - const processHandle = broker.spawn( - "connector", - { transport: "local" }, - { command: "omp", shellMode: "interactive" }, - ); - const hosts = broker.listSshHosts("connector"); - const processExit = expect(processHandle.exit).resolves.toMatchObject({ - code: null, - error: expect.objectContaining({ - message: expect.stringContaining("disconnected"), - }), - }); - const requestFailure = expect(hosts).rejects.toThrow("disconnected"); - - unregister(); - await vi.advanceTimersByTimeAsync(1_000); - await Promise.all([processExit, requestFailure]); - } finally { - vi.useRealTimers(); - } + expect( + broker.acceptBatch("connector", "epoch-a", [ + response(1, "request-a", null), + ]), + ).toEqual({ connectorEpoch: "epoch-a", acknowledgedSequence: 1 }); + expect( + broker.acceptBatch("connector", "epoch-b", [ + response(1, "request-b", null), + ]), + ).toEqual({ connectorEpoch: "epoch-b", acknowledgedSequence: 1 }); + expect( + broker.acceptBatch("connector", "epoch-a", [ + response(2, "request-a2", null), + ]), + ).toEqual({ connectorEpoch: "epoch-a", acknowledgedSequence: 2 }); }); - it("settles a process immediately when an offline kill cannot be sent", async () => { - const broker = new HostConnectorBroker(); + it("deduplicates session timeline events by daemon epoch and sequence", async () => { const commands: HostConnectorCommand[] = []; - const unregister = broker.register("connector", (command) => - commands.push(command), - ); - const processHandle = broker.spawn( + const received: number[] = []; + const broker = new HostConnectorBroker(); + broker.register("connector", ["session"], (command) => commands.push(command)); + const subscribed = broker.subscribeSession( "connector", - { transport: "local" }, - { command: "omp", shellMode: "interactive" }, + session, + undefined, + (envelope) => received.push(envelope.sequence), + vi.fn(), ); - unregister(); - - expect(processHandle.kill("SIGKILL")).toBe(false); - await expect(processHandle.exit).resolves.toMatchObject({ - code: null, - error: expect.objectContaining({ - message: "The OvertChat Host Connector is offline.", - }), - }); - - const reconnectCommands: HostConnectorCommand[] = []; - broker.register("connector", (command) => reconnectCommands.push(command)); - expect(reconnectCommands[0]).toEqual({ type: "sync", processIds: [] }); - }); - - it("settles a killed process when its exit event is lost", async () => { - vi.useFakeTimers(); - try { - const broker = new HostConnectorBroker(); - const commands: HostConnectorCommand[] = []; - broker.register("connector", (command) => commands.push(command)); - const processHandle = broker.spawn( - "connector", - { transport: "local" }, - { command: "omp", shellMode: "interactive" }, - ); - const processExit = expect(processHandle.exit).resolves.toMatchObject({ - code: null, - signal: "SIGKILL", - error: expect.objectContaining({ - message: expect.stringContaining("did not confirm process exit"), - }), - }); - - expect(processHandle.kill("SIGKILL")).toBe(true); - await vi.advanceTimersByTimeAsync(5_000); - - await processExit; - } finally { - vi.useRealTimers(); - } - }); - - it("validates SSH host discovery responses", async () => { - const broker = new HostConnectorBroker(); - const commands: HostConnectorCommand[] = []; - broker.register("connector", (command) => commands.push(command)); - - const hostsPromise = broker.listSshHosts("connector"); - const request = commands.find( - ( - command, - ): command is Extract => - command.type === "request", - )!; - broker.accept("connector", { - type: "response", - requestId: request.requestId, - success: true, - data: [ - { - alias: "macbook", - hostname: "100.64.0.5", - port: 22, - username: "yash", + const request = commands.at(-1); + if (request?.type !== "request") throw new Error("missing request"); + broker.acceptBatch("connector", "daemon-epoch", [ + response(1, request.requestId, { subscribed: true }), + ]); + const unsubscribe = await subscribed; + const event: HostConnectorEvent = { + sequence: 2, + payload: { + type: "session_event", + subscriptionId: + request.request.type === "subscribe_session" + ? request.request.subscriptionId + : "missing", + sessionId: "session", + envelope: { + epoch: "runtime-epoch", + sequence: 1, + type: "runtime_event", + data: { type: "turn_start" }, }, - ], - }); - - await expect(hostsPromise).resolves.toEqual([ - expect.objectContaining({ alias: "macbook" }), + }, + }; + broker.acceptBatch("connector", "daemon-epoch", [event]); + broker.acceptBatch("connector", "daemon-epoch", [event]); + broker.acceptBatch("connector", "daemon-epoch", [ + { + ...event, + sequence: 3, + }, ]); - const invalidPromise = broker.listSshHosts("connector"); - const invalidRequest = commands.at(-1) as Extract< - HostConnectorCommand, - { type: "request" } - >; - broker.accept("connector", { - type: "response", - requestId: invalidRequest.requestId, - success: true, - data: [{ alias: "broken", port: "22" }], - }); - await expect(invalidPromise).rejects.toThrow("invalid SSH host list"); - }); - - it("rejects process creation while the connector is offline", () => { - const broker = new HostConnectorBroker(); - - expect(() => - broker.spawn( - "offline", - { transport: "local" }, - { command: "omp", shellMode: "interactive" }, - ), - ).toThrow("Host Connector is offline"); + expect(received).toEqual([1]); + unsubscribe(); }); }); diff --git a/apps/web/lib/agents/connector/broker.ts b/apps/web/lib/agents/connector/broker.ts index aa97c59a..37de4c18 100644 --- a/apps/web/lib/agents/connector/broker.ts +++ b/apps/web/lib/agents/connector/broker.ts @@ -1,36 +1,22 @@ import "server-only"; -import { PassThrough, Writable } from "node:stream"; import { isConnectorSshHost, - type ConnectorProcessLaunch, + type AgentDaemonRequest, + type AgentDaemonSessionDescriptor, + type AgentRuntimeEnvelope, + type AgentRuntimeStatus, type ConnectorSshHost, - type ConnectorTarget, type HostConnectorCommand, type HostConnectorEvent, + type HostConnectorEventAck, + type HostConnectorEventPayload, } from "@overtchat/agent-bridge"; +import { updateAgentSessionMetadata } from "@/lib/db/agentConnections"; -const REQUEST_TIMEOUT_MS = 15_000; +const REQUEST_TIMEOUT_MS = 180_000; const CONNECTOR_DISCONNECT_GRACE_MS = 5_000; -const PROCESS_EXIT_GRACE_MS = 5_000; -const STDIN_CHUNK_BYTES = 64 * 1024; -export type ConnectorProcessExit = { - code: number | null; - signal: NodeJS.Signals | null; - error?: Error; -}; - -export type ConnectorProcess = { - stdin: Writable; - stdout: PassThrough; - stderr: PassThrough; - exit: Promise; - kill(signal?: NodeJS.Signals): boolean; -}; - -type Channel = { - send: (command: HostConnectorCommand) => void; -}; +type Channel = { send: (command: HostConnectorCommand) => void }; type PendingRequest = { connectorId: string; @@ -39,17 +25,20 @@ type PendingRequest = { timeout: NodeJS.Timeout; }; -type ProcessEntry = { +type SessionSubscription = { connectorId: string; - stdout: PassThrough; - stderr: PassThrough; - finish: (exit: ConnectorProcessExit) => void; + session: AgentDaemonSessionDescriptor; + after?: { epoch: string; sequence: number }; + subscriber: (envelope: AgentRuntimeEnvelope) => void; + disconnect: (error: Error) => void; }; export class HostConnectorBroker { private readonly channels = new Map(); - private readonly processes = new Map(); private readonly pending = new Map(); + private readonly subscriptions = new Map(); + private readonly cursors = new Map(); + private readonly sessionStatuses = new Map(); private readonly disconnectTimers = new Map(); constructor( @@ -60,135 +49,116 @@ export class HostConnectorBroker { return this.channels.has(connectorId); } + runtimeStatusForSession(sessionId: string): AgentRuntimeStatus { + return this.sessionStatuses.get(sessionId) ?? "idle"; + } + register( connectorId: string, + activeSessionIds: string[], send: (command: HostConnectorCommand) => void, ): () => void { this.clearDisconnectTimer(connectorId); const channel = { send }; this.channels.set(connectorId, channel); - try { - send({ - type: "sync", - processIds: [...this.processes] - .filter(([, process]) => process.connectorId === connectorId) - .map(([id]) => id), - }); - } catch (error) { + send({ + type: "sync", + connectionEpoch: crypto.randomUUID(), + activeSessionIds, + }); + void this.resubscribe(connectorId); + return () => { + if (this.channels.get(connectorId) !== channel) return; this.channels.delete(connectorId); this.scheduleDisconnect(connectorId); - throw error; - } - return () => { - if (this.channels.get(connectorId) === channel) { - this.channels.delete(connectorId); - this.scheduleDisconnect(connectorId); - } }; } - spawn( + request( connectorId: string, - target: ConnectorTarget, - launch: ConnectorProcessLaunch, - ): ConnectorProcess { - const processId = crypto.randomUUID(); - const stdout = new PassThrough(); - const stderr = new PassThrough(); - let settled = false; - let killTimer: NodeJS.Timeout | undefined; - let settle: (exit: ConnectorProcessExit) => void = () => {}; - const exit = new Promise((resolve) => { - settle = resolve; - }); - const finish = (result: ConnectorProcessExit) => { - if (settled) return; - settled = true; - if (killTimer) clearTimeout(killTimer); - this.processes.delete(processId); - stdout.end(); - stderr.end(); - settle(result); - }; - const send = (command: HostConnectorCommand) => { - const channel = this.channels.get(connectorId); - if (!channel) throw new Error("The OvertChat Host Connector is offline."); - channel.send(command); - }; - const stdin = new Writable({ - write(chunk, _encoding, callback) { - try { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - for (let offset = 0; offset < bytes.length; offset += STDIN_CHUNK_BYTES) { - send({ - type: "stdin", - processId, - data: bytes - .subarray(offset, offset + STDIN_CHUNK_BYTES) - .toString("base64"), - }); - } - callback(); - } catch (error) { - callback(error instanceof Error ? error : new Error(String(error))); - } - }, - final(callback) { - try { - send({ type: "stdin_end", processId }); - callback(); - } catch (error) { - callback(error instanceof Error ? error : new Error(String(error))); - } - }, + request: AgentDaemonRequest, + ): Promise { + const channel = this.channels.get(connectorId); + if (!channel) { + return Promise.reject( + new Error("The OvertChat Host Connector is offline."), + ); + } + const requestId = crypto.randomUUID(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error("Timed out waiting for the Host Connector.")); + }, REQUEST_TIMEOUT_MS); + timeout.unref(); + this.pending.set(requestId, { + connectorId, + resolve: (value) => resolve(value as T), + reject, + timeout, + }); + try { + channel.send({ type: "request", requestId, request }); + } catch (error) { + clearTimeout(timeout); + this.pending.delete(requestId); + reject(error instanceof Error ? error : new Error(String(error))); + } }); - this.processes.set(processId, { + } + + async subscribeSession( + connectorId: string, + session: AgentDaemonSessionDescriptor, + after: { epoch: string; sequence: number } | undefined, + subscriber: (envelope: AgentRuntimeEnvelope) => void, + disconnect: (error: Error) => void, + ): Promise<() => void> { + const subscriptionId = crypto.randomUUID(); + this.subscriptions.set(subscriptionId, { connectorId, - stdout, - stderr, - finish, + session, + after, + subscriber, + disconnect, }); try { - send({ type: "spawn", processId, target, launch }); - } catch (error) { - finish({ - code: null, - signal: null, - error: error instanceof Error ? error : new Error(String(error)), + await this.request(connectorId, { + type: "subscribe_session", + subscriptionId, + session, + ...(after ? { after } : {}), }); + } catch (error) { + this.subscriptions.delete(subscriptionId); throw error; } + return () => { + if (!this.subscriptions.delete(subscriptionId)) return; + void this.request(connectorId, { + type: "unsubscribe_session", + subscriptionId, + }).catch(() => {}); + }; + } + + acceptBatch( + connectorId: string, + connectorEpoch: string, + events: readonly HostConnectorEvent[], + ): HostConnectorEventAck { + const cursorKey = `${connectorId}:${connectorEpoch}`; + let acknowledgedSequence = this.cursors.get(cursorKey) ?? 0; + for (const event of events) { + if (event.sequence <= acknowledgedSequence) continue; + if (event.sequence !== acknowledgedSequence + 1) break; + this.accept(connectorId, event.payload); + acknowledgedSequence = event.sequence; + } + this.cursors.set(cursorKey, acknowledgedSequence); return { - stdin, - stdout, - stderr, - exit, - kill: (signal = "SIGTERM") => { - if (settled) return false; - try { - send({ type: "kill", processId, signal }); - if (killTimer) clearTimeout(killTimer); - killTimer = setTimeout(() => { - finish({ - code: null, - signal, - error: new Error( - `The Host Connector did not confirm process exit after ${signal}.`, - ), - }); - }, PROCESS_EXIT_GRACE_MS); - killTimer.unref(); - return true; - } catch (error) { - finish({ - code: null, - signal: null, - error: - error instanceof Error ? error : new Error(String(error)), - }); - return false; - } - }, + connectorEpoch, + acknowledgedSequence, }; } @@ -200,7 +170,7 @@ export class HostConnectorBroker { return value; } - accept(connectorId: string, event: HostConnectorEvent): void { + private accept(connectorId: string, event: HostConnectorEventPayload): void { if (event.type === "response") { const pending = this.pending.get(event.requestId); if (!pending || pending.connectorId !== connectorId) return; @@ -210,19 +180,65 @@ export class HostConnectorBroker { else pending.reject(new Error(event.error)); return; } - const process = this.processes.get(event.processId); - if (!process || process.connectorId !== connectorId) return; - if (event.type === "stdout" || event.type === "stderr") { - const stream = event.type === "stdout" ? process.stdout : process.stderr; - stream.write(Buffer.from(event.data, "base64")); + if (event.type === "session_metadata") { + const { providerModifiedAt, ...metadata } = event.patch; + void updateAgentSessionMetadata(event.sessionId, { + ...metadata, + ...(providerModifiedAt !== undefined + ? { providerModifiedAt: new Date(providerModifiedAt) } + : {}), + }); return; } - if (event.type !== "exit") return; - process.finish({ - code: event.code, - signal: event.signal, - ...(event.error ? { error: new Error(event.error) } : {}), - }); + const subscription = this.subscriptions.get(event.subscriptionId); + if ( + !subscription || + subscription.connectorId !== connectorId || + subscription.session.sessionId !== event.sessionId + ) { + return; + } + const cursor = subscription.after; + if ( + cursor?.epoch === event.envelope.epoch && + event.envelope.sequence <= cursor.sequence + ) { + return; + } + subscription.after = { + epoch: event.envelope.epoch, + sequence: event.envelope.sequence, + }; + if (event.envelope.type === "snapshot") { + this.sessionStatuses.set(event.sessionId, event.envelope.data.status); + } else if ( + event.envelope.data.type === "overtchat_status" && + ["idle", "running", "exited"].includes( + String(event.envelope.data.status), + ) + ) { + this.sessionStatuses.set( + event.sessionId, + event.envelope.data.status as AgentRuntimeStatus, + ); + } + subscription.subscriber(event.envelope); + } + + private async resubscribe(connectorId: string): Promise { + const matching = [...this.subscriptions.entries()].filter( + ([, subscription]) => subscription.connectorId === connectorId, + ); + await Promise.allSettled( + matching.map(([subscriptionId, subscription]) => + this.request(connectorId, { + type: "subscribe_session", + subscriptionId, + session: subscription.session, + ...(subscription.after ? { after: subscription.after } : {}), + }), + ), + ); } private clearDisconnectTimer(connectorId: string): void { @@ -242,58 +258,22 @@ export class HostConnectorBroker { return; } this.disconnectTimers.delete(connectorId); - const error = new Error( - "The OvertChat Host Connector disconnected while the agent was running.", - ); - for (const process of [...this.processes.values()]) { - if (process.connectorId !== connectorId) continue; - process.finish({ code: null, signal: null, error }); - } + const error = new Error("The OvertChat Host Connector is offline."); for (const [requestId, request] of this.pending) { if (request.connectorId !== connectorId) continue; clearTimeout(request.timeout); this.pending.delete(requestId); request.reject(error); } + for (const [subscriptionId, subscription] of this.subscriptions) { + if (subscription.connectorId !== connectorId) continue; + this.subscriptions.delete(subscriptionId); + subscription.disconnect(error); + } }, this.disconnectGraceMs); timer.unref(); this.disconnectTimers.set(connectorId, timer); } - - private request( - connectorId: string, - request: Extract< - HostConnectorCommand, - { type: "request" } - >["request"], - ): Promise { - const channel = this.channels.get(connectorId); - if (!channel) { - return Promise.reject( - new Error("The OvertChat Host Connector is offline."), - ); - } - const requestId = crypto.randomUUID(); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(requestId); - reject(new Error("Timed out waiting for the Host Connector.")); - }, REQUEST_TIMEOUT_MS); - this.pending.set(requestId, { - connectorId, - resolve, - reject, - timeout, - }); - try { - channel.send({ type: "request", requestId, request }); - } catch (error) { - clearTimeout(timeout); - this.pending.delete(requestId); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } } const globalForHostConnector = globalThis as typeof globalThis & { diff --git a/apps/web/lib/agents/connector/descriptors.ts b/apps/web/lib/agents/connector/descriptors.ts new file mode 100644 index 00000000..b3edf007 --- /dev/null +++ b/apps/web/lib/agents/connector/descriptors.ts @@ -0,0 +1,92 @@ +import "server-only"; +import type { + AgentDaemonSessionDescriptor, + AgentDaemonTarget, + AgentDaemonWorkspaceDescriptor, + AgentProviderSessionMetadata, + AgentProviderId, + ConnectorShellMode, +} from "@overtchat/agent-bridge"; +import type { + AgentHostRow, + OwnedAgentSession, + OwnedAgentWorkspace, +} from "@/lib/db/agentConnections"; + +export function daemonTarget( + host: Pick, + shellMode?: ConnectorShellMode, +): AgentDaemonTarget { + return host.transport === "local" + ? { transport: "local", shellMode } + : { + transport: "ssh", + alias: host.sshAlias ?? "", + shellMode, + }; +} + +export function daemonWorkspace( + owned: OwnedAgentWorkspace, +): AgentDaemonWorkspaceDescriptor { + return { + connectionId: owned.connection.id, + workspaceId: owned.workspace.id, + provider: owned.connection.provider as AgentProviderId, + target: daemonTarget(owned.host, owned.connection.shellMode), + executable: owned.connection.executable, + cwd: owned.workspace.path, + detectedVersion: owned.connection.detectedVersion, + }; +} + +export function daemonSession( + owned: OwnedAgentSession, +): AgentDaemonSessionDescriptor { + return { + ...daemonWorkspace(owned), + sessionId: owned.agentSession.id, + providerSessionId: owned.agentSession.providerSessionId, + providerSessionPath: owned.agentSession.providerSessionPath, + }; +} + +function optionalDate(value: unknown): Date | null { + if (value === null || value === undefined) return null; + const date = new Date(value as string | number | Date); + return Number.isFinite(date.getTime()) ? date : null; +} + +export function parseProviderSessionMetadata( + value: unknown, +): AgentProviderSessionMetadata { + if (!value || typeof value !== "object") { + throw new Error("The Host Connector returned invalid session metadata."); + } + const providerSessionId = Reflect.get(value, "providerSessionId"); + const providerSessionPath = Reflect.get(value, "providerSessionPath"); + const name = Reflect.get(value, "name"); + const firstMessage = Reflect.get(value, "firstMessage"); + const messageCount = Reflect.get(value, "messageCount"); + if ( + typeof providerSessionId !== "string" || + !providerSessionId || + typeof providerSessionPath !== "string" || + !providerSessionPath || + (name !== null && typeof name !== "string") || + (firstMessage !== null && typeof firstMessage !== "string") || + !Number.isSafeInteger(messageCount) || + Number(messageCount) < 0 + ) { + throw new Error("The Host Connector returned invalid session metadata."); + } + return { + providerSessionId, + providerSessionPath, + name, + firstMessage, + messageCount: Number(messageCount), + createdAt: optionalDate(Reflect.get(value, "createdAt")), + modifiedAt: optionalDate(Reflect.get(value, "modifiedAt")), + }; +} diff --git a/apps/web/lib/agents/runtime/status.test.ts b/apps/web/lib/agents/connector/status.test.ts similarity index 78% rename from apps/web/lib/agents/runtime/status.test.ts rename to apps/web/lib/agents/connector/status.test.ts index f5491d7f..6f05aedc 100644 --- a/apps/web/lib/agents/runtime/status.test.ts +++ b/apps/web/lib/agents/connector/status.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentConnectionListItem } from "@/lib/agents/types"; +import type { AgentConnectionListItem } from "@overtchat/agent-bridge"; const mocks = vi.hoisted(() => ({ runtimeStatusForSession: vi.fn(), })); vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/runtime/registry", () => ({ - agentRuntimeRegistry: { +vi.mock("@/lib/agents/connector/broker", () => ({ + hostConnectorBroker: { runtimeStatusForSession: mocks.runtimeStatusForSession, }, })); @@ -56,8 +56,8 @@ describe("agent connection runtime statuses", () => { mocks.runtimeStatusForSession.mockReturnValue("running"); }); - it("overlays owner-scoped in-memory status without mutating database data", () => { - const result = withAgentRuntimeStatuses(connections, "user"); + it("overlays connector-projected status without mutating database data", () => { + const result = withAgentRuntimeStatuses(connections); expect(result[0]!.workspaces[0]!.sessions[0]!.runtimeStatus).toBe( "running", @@ -65,9 +65,6 @@ describe("agent connection runtime statuses", () => { expect(connections[0]!.workspaces[0]!.sessions[0]!.runtimeStatus).toBe( "idle", ); - expect(mocks.runtimeStatusForSession).toHaveBeenCalledWith( - "session", - "user", - ); + expect(mocks.runtimeStatusForSession).toHaveBeenCalledWith("session"); }); }); diff --git a/apps/web/lib/agents/runtime/status.ts b/apps/web/lib/agents/connector/status.ts similarity index 58% rename from apps/web/lib/agents/runtime/status.ts rename to apps/web/lib/agents/connector/status.ts index c81d3904..ddc90562 100644 --- a/apps/web/lib/agents/runtime/status.ts +++ b/apps/web/lib/agents/connector/status.ts @@ -1,10 +1,9 @@ import "server-only"; -import type { AgentConnectionListItem } from "@/lib/agents/types"; -import { agentRuntimeRegistry } from "@/lib/agents/runtime/registry"; +import type { AgentConnectionListItem } from "@overtchat/agent-bridge"; +import { hostConnectorBroker } from "./broker"; export function withAgentRuntimeStatuses( connections: AgentConnectionListItem[], - userId: string, ): AgentConnectionListItem[] { return connections.map((connection) => ({ ...connection, @@ -12,10 +11,7 @@ export function withAgentRuntimeStatuses( ...workspace, sessions: workspace.sessions.map((session) => ({ ...session, - runtimeStatus: agentRuntimeRegistry.runtimeStatusForSession( - session.id, - userId, - ), + runtimeStatus: hostConnectorBroker.runtimeStatusForSession(session.id), })), })), })); diff --git a/apps/web/lib/agents/providers/registry.ts b/apps/web/lib/agents/providers/registry.ts deleted file mode 100644 index 24121a66..00000000 --- a/apps/web/lib/agents/providers/registry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import "server-only"; -import type { AgentProviderAdapter } from "@/lib/agents/providers/types"; -import { createPiRpcProviderAdapter } from "@/lib/agents/providers/pi-rpc"; -import { codexProviderAdapter } from "@/lib/agents/providers/codex"; -import type { AgentProviderId } from "@/lib/agents/types"; - -const adapters = { - pi: createPiRpcProviderAdapter("pi"), - omp: createPiRpcProviderAdapter("omp"), - codex: codexProviderAdapter, -} satisfies Record; - -export function agentProviderAdapter( - provider: AgentProviderId, -): AgentProviderAdapter { - return adapters[provider]; -} diff --git a/apps/web/lib/agents/runtime/images.test.ts b/apps/web/lib/agents/runtime/images.test.ts deleted file mode 100644 index af941a25..00000000 --- a/apps/web/lib/agents/runtime/images.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - getUpload: vi.fn(), - uploadPath: vi.fn(), - readFile: vi.fn(), -})); - -vi.mock("server-only", () => ({})); -vi.mock("node:fs/promises", () => ({ - default: { readFile: mocks.readFile }, -})); -vi.mock("@/lib/db/uploads", () => ({ - getUpload: mocks.getUpload, - uploadPath: mocks.uploadPath, -})); - -import { - MAX_AGENT_IMAGE_BYTES, - MAX_AGENT_IMAGE_TOTAL_BYTES, -} from "@/lib/agents/types"; -import { resolveAgentImages } from "./images"; - -const image = { - uploadId: "11111111-1111-4111-8111-111111111111", - filename: "client-name.png", - mediaType: "image/png" as const, -}; - -describe("agent image resolution", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.uploadPath.mockImplementation((id: string) => `/uploads/${id}`); - mocks.readFile.mockResolvedValue(Buffer.from("image-bytes")); - mocks.getUpload.mockResolvedValue({ - id: image.uploadId, - userId: "user", - filename: "stored-name.png", - mediaType: "image/png", - category: "image", - size: 11, - }); - }); - - it("resolves owner-scoped uploads using authoritative metadata", async () => { - await expect(resolveAgentImages([image], "user")).resolves.toEqual([ - { - uploadId: image.uploadId, - filename: "stored-name.png", - mediaType: "image/png", - data: Buffer.from("image-bytes").toString("base64"), - }, - ]); - expect(mocks.getUpload).toHaveBeenCalledWith(image.uploadId, "user"); - expect(mocks.readFile).toHaveBeenCalledWith(`/uploads/${image.uploadId}`); - }); - - it("rejects unavailable, non-image, and oversized uploads", async () => { - mocks.getUpload.mockResolvedValueOnce(null); - await expect(resolveAgentImages([image], "user")).rejects.toThrow( - "unavailable", - ); - - mocks.getUpload.mockResolvedValueOnce({ - id: image.uploadId, - category: "text", - mediaType: "text/plain", - size: 10, - }); - await expect(resolveAgentImages([image], "user")).rejects.toThrow( - "unavailable", - ); - - mocks.getUpload.mockResolvedValueOnce({ - id: image.uploadId, - category: "image", - mediaType: "image/png", - size: MAX_AGENT_IMAGE_BYTES + 1, - }); - await expect(resolveAgentImages([image], "user")).rejects.toThrow( - "10MB", - ); - }); - - it("rejects image batches above the aggregate limit", async () => { - mocks.getUpload.mockResolvedValue({ - id: image.uploadId, - filename: "large.png", - category: "image", - mediaType: "image/png", - size: Math.floor(MAX_AGENT_IMAGE_TOTAL_BYTES / 3) + 1, - }); - - await expect( - resolveAgentImages( - [ - image, - { - ...image, - uploadId: "22222222-2222-4222-8222-222222222222", - }, - { - ...image, - uploadId: "33333333-3333-4333-8333-333333333333", - }, - ], - "user", - ), - ).rejects.toThrow("total 20MB"); - }); -}); diff --git a/apps/web/lib/agents/runtime/images.ts b/apps/web/lib/agents/runtime/images.ts deleted file mode 100644 index 40547c5b..00000000 --- a/apps/web/lib/agents/runtime/images.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "server-only"; -import fs from "node:fs/promises"; -import { - AGENT_IMAGE_MEDIA_TYPES, - MAX_AGENT_IMAGE_BYTES, - MAX_AGENT_IMAGE_TOTAL_BYTES, - type AgentPromptImage, -} from "@/lib/agents/types"; -import type { ResolvedAgentImage } from "@/lib/agents/providers/types"; -import { getUpload, uploadPath } from "@/lib/db/uploads"; - -const allowedMediaTypes = new Set(AGENT_IMAGE_MEDIA_TYPES); - -export async function resolveAgentImages( - images: readonly AgentPromptImage[] | undefined, - userId: string, -): Promise { - if (!images?.length) return []; - let totalBytes = 0; - const resolved: ResolvedAgentImage[] = []; - - for (const image of images) { - const upload = await getUpload(image.uploadId, userId); - if ( - !upload || - upload.category !== "image" || - !allowedMediaTypes.has(upload.mediaType) - ) { - throw new Error("An attached image is unavailable."); - } - if (upload.size > MAX_AGENT_IMAGE_BYTES) { - throw new Error("Agent images must be 10MB or smaller."); - } - totalBytes += upload.size; - if (totalBytes > MAX_AGENT_IMAGE_TOTAL_BYTES) { - throw new Error("Agent image attachments must total 20MB or less."); - } - const bytes = await fs.readFile(uploadPath(upload.id)); - resolved.push({ - uploadId: upload.id, - filename: upload.filename, - mediaType: upload.mediaType as AgentPromptImage["mediaType"], - data: bytes.toString("base64"), - }); - } - - return resolved; -} diff --git a/apps/web/lib/agents/runtime/process.test.ts b/apps/web/lib/agents/runtime/process.test.ts deleted file mode 100644 index 34ed1602..00000000 --- a/apps/web/lib/agents/runtime/process.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { PassThrough, Writable } from "node:stream"; -import { describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - spawn: vi.fn(), -})); - -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/connector/broker", () => ({ - hostConnectorBroker: { - spawn: mocks.spawn, - }, -})); - -import { executeOnHost } from "./process"; - -describe("agent host process execution", () => { - it("rejects after the command timeout even when process exit never arrives", async () => { - vi.useFakeTimers(); - try { - const kill = vi.fn(() => true); - mocks.spawn.mockReturnValue({ - stdin: new Writable({ - write(_chunk, _encoding, callback) { - callback(); - }, - }), - stdout: new PassThrough(), - stderr: new PassThrough(), - exit: new Promise(() => {}), - kill, - }); - - const execution = executeOnHost( - { connectorId: "connector", transport: "local" }, - { command: "omp", args: ["--version"] }, - { timeoutMs: 1_000 }, - ); - const failure = expect(execution).rejects.toThrow( - "timed out after 1000 milliseconds", - ); - - await vi.advanceTimersByTimeAsync(1_000); - - await failure; - expect(kill).toHaveBeenCalledWith("SIGKILL"); - expect(mocks.spawn).toHaveBeenCalledWith( - "connector", - { transport: "local" }, - { - command: "omp", - args: ["--version"], - shellMode: "interactive", - }, - ); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/apps/web/lib/agents/runtime/registry.test.ts b/apps/web/lib/agents/runtime/registry.test.ts deleted file mode 100644 index e5585b13..00000000 --- a/apps/web/lib/agents/runtime/registry.test.ts +++ /dev/null @@ -1,1496 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { - AgentModel, - AgentRuntimeEnvelope, - AgentSessionStats, -} from "@/lib/agents/types"; - -const mocks = vi.hoisted(() => ({ - startPiRpc: vi.fn(), - resolveAgentImages: vi.fn(), - getOwnedAgentSession: vi.fn(), - updateAgentSessionMetadata: vi.fn(), - upsertAgentSession: vi.fn(), -})); - -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/pi/client", () => ({ - startPiRpc: mocks.startPiRpc, -})); -vi.mock("@/lib/agents/runtime/images", () => ({ - resolveAgentImages: mocks.resolveAgentImages, -})); -vi.mock("@/lib/db/agentConnections", () => ({ - getOwnedAgentSession: mocks.getOwnedAgentSession, - updateAgentSessionMetadata: mocks.updateAgentSessionMetadata, - upsertAgentSession: mocks.upsertAgentSession, -})); - -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; -import type { - AgentRuntimeClient, - AgentRuntimeEvent, -} from "@/lib/agents/providers/types"; -import type { OwnedAgentSession } from "@/lib/db/agentConnections"; -import { - AgentRuntimeRegistry, - AgentSessionRuntime, -} from "./registry"; - -const stats: AgentSessionStats = { - sessionFile: "/sessions/native.jsonl", - sessionId: "native", - userMessages: 0, - assistantMessages: 0, - toolCalls: 0, - toolResults: 0, - totalMessages: 0, - tokens: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - cost: 0, -}; - -const model: AgentModel = { - id: "model", - name: "Model", - provider: "provider", - api: "api", - baseUrl: "", - reasoning: true, - input: ["text"], - contextWindow: 100_000, - maxTokens: 10_000, - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, -}; - -const idleProviderState = { - sessionFile: "/sessions/native.jsonl", - sessionId: "native", - sessionName: null, - model, - thinkingLevel: "medium", - autoCompactionEnabled: false, - isStreaming: false, - isCompacting: false, -}; - -class FakeAgentClient { - private listeners = new Set<(event: AgentRuntimeEvent) => void>(); - readonly stop = vi.fn(async () => {}); - readonly prompt = vi.fn(async () => ({})); - readonly steer = vi.fn(async () => ({})); - readonly followUp = vi.fn(async () => ({})); - readonly abort = vi.fn(async () => ({})); - readonly setModel = vi.fn(async () => ({})); - readonly setThinkingLevel = vi.fn(async () => ({})); - readonly setCollaborationMode = vi.fn(async () => ({})); - readonly setFastMode = vi.fn(async () => ({})); - readonly updateGoal = vi.fn(async () => null); - readonly compact = vi.fn(async () => ({})); - readonly setAutoCompaction = vi.fn(async () => ({})); - readonly setSessionName = vi.fn(async () => ({})); - readonly retryInteractive = vi.fn(async () => ({})); - readonly forkSession = vi.fn(async () => ({ - session: { - providerSessionId: "native-fork", - providerSessionPath: "/sessions/native-fork.jsonl", - name: null, - firstMessage: "Earlier prompt", - messageCount: 2, - createdAt: new Date(1_000), - modifiedAt: new Date(2_000), - }, - draft: "Edit this prompt", - })); - readonly discardForkedSession = vi.fn(async () => {}); - readonly respondToInteraction = vi.fn(); - readonly respondToExtensionUi = this.respondToInteraction; - readonly getState = vi.fn(async () => ({ ...idleProviderState })); - readonly getMessages = vi.fn(async () => ({ messages: [] })); - readonly getAvailableModels = vi.fn(async () => [model]); - readonly getSessionStats = vi.fn(async () => stats); - readonly getAvailableThinkingLevels = vi.fn(async () => [ - "off", - "medium", - ]); - readonly getCommands = vi.fn(async () => []); - - onEvent(listener: (event: AgentRuntimeEvent) => void) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - emit(event: AgentRuntimeEvent) { - for (const listener of this.listeners) listener(event); - } -} - -function initial() { - return { - state: { - sessionFile: "/sessions/native.jsonl", - sessionId: "native", - model, - thinkingLevel: "medium", - isStreaming: false, - }, - messages: [], - models: [model], - thinkingLevels: ["off", "medium"] as const, - commands: [], - stats, - }; -} - -function owned(): OwnedAgentSession { - return { - host: { - id: "host", - userId: "user", - connectorId: "connector", - name: "This machine", - transport: "local", - sshAlias: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - connection: { - id: "connection", - hostId: "host", - provider: "pi", - executable: "pi", - shellMode: "interactive", - detectedVersion: "0.82.1", - lastValidatedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - workspace: { - id: "workspace", - connectionId: "connection", - path: "/workspace", - name: "workspace", - createdAt: new Date(), - updatedAt: new Date(), - }, - agentSession: { - id: "session", - workspaceId: "workspace", - providerSessionId: "native", - providerSessionPath: "/sessions/native.jsonl", - name: null, - firstMessage: null, - messageCount: 0, - providerCreatedAt: null, - providerModifiedAt: null, - lastSyncedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - }; -} - -describe("Agent session runtime", () => { - it("blocks mutations in read-only sessions and retries interactive access", async () => { - const client = new FakeAgentClient(); - const readOnly = { - reason: "This session is open elsewhere.", - retryable: true, - }; - client.getState.mockResolvedValueOnce({ ...idleProviderState }); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - state: { ...initial().state, readOnly }, - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - expect(runtime.snapshot().readOnly).toEqual(readOnly); - await expect( - runtime.command({ type: "prompt", message: "Continue" }), - ).rejects.toThrow("open elsewhere"); - expect(client.prompt).not.toHaveBeenCalled(); - - await runtime.command({ type: "retry_interactive" }); - expect(client.retryInteractive).toHaveBeenCalledTimes(1); - expect(runtime.snapshot().readOnly).toBeUndefined(); - await runtime.stop(); - }); - - it("keeps live provider messages in authoritative snapshots", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "Inspect the runtime" }); - const startedAt = runtime.snapshot().activeTurn?.startedAt; - client.emit({ - type: "message_end", - message: { - role: "user", - content: "Inspect the runtime", - timestamp: 10, - }, - }); - client.emit({ - type: "message_update", - message: { - role: "assistant", - content: [{ type: "text", text: "Still working" }], - timestamp: 20, - }, - }); - - expect(startedAt).toEqual(expect.any(Number)); - expect(runtime.snapshot()).toMatchObject({ - capabilities: { steer: true }, - status: "running", - activeTurn: { startedAt }, - messages: [ - { - role: "user", - content: "Inspect the runtime", - timestamp: 10, - }, - { - role: "assistant", - content: [{ type: "text", text: "Still working" }], - timestamp: 20, - }, - ], - }); - - client.emit({ type: "agent_settled" }); - await vi.waitFor(() => { - expect(runtime.snapshot().activeTurn).toBeNull(); - }); - await runtime.stop(); - }); - - it("keeps provider built-ins when discovered commands refresh", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - client.emit({ - type: "available_commands_update", - commands: [ - { - name: "release-notes", - description: "Draft release notes", - source: "extension", - }, - ], - }); - - expect(runtime.snapshot().commands).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "compact" }), - expect.objectContaining({ name: "new" }), - expect.objectContaining({ name: "release-notes" }), - ]), - ); - await runtime.stop(); - }); - - it("moves an accepted prompt into the transcript without duplicating an early provider echo", async () => { - const client = new FakeAgentClient(); - let acceptPrompt: () => void = vi.fn(); - client.prompt.mockImplementationOnce( - () => - new Promise((resolve) => { - acceptPrompt = () => resolve({}); - }), - ); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - const submitting = runtime.command({ - type: "prompt", - message: "Inspect the runtime", - }); - expect(runtime.snapshot().messages).toEqual([]); - - client.emit({ - type: "message_start", - message: { - role: "user", - content: "Inspect the runtime", - timestamp: 10, - }, - }); - acceptPrompt(); - await submitting; - - expect(runtime.snapshot().messages).toEqual([ - { - role: "user", - content: "Inspect the runtime", - timestamp: 10, - }, - ]); - await runtime.stop(); - }); - - it("executes Overtchat slash commands through native Pi RPC methods", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - state: { - ...initial().state, - autoCompactionEnabled: false, - }, - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ - type: "prompt", - message: "/compact focus on tests", - }); - await runtime.command({ - type: "prompt", - message: "/autocompact", - }); - await runtime.command({ - type: "prompt", - message: "/name Release prep", - }); - - expect(client.compact).toHaveBeenCalledWith("focus on tests"); - expect(client.setAutoCompaction).toHaveBeenCalledWith(true); - expect(client.setSessionName).toHaveBeenCalledWith("Release prep"); - expect(client.prompt).not.toHaveBeenCalled(); - await runtime.stop(); - }); - - it("runs Codex goals and plan handoff through native runtime controls", async () => { - const client = new FakeAgentClient(); - const codexProviderState = { - ...idleProviderState, - goalsSupported: true, - collaborationMode: "default", - collaborationModes: ["default", "plan"], - fastModeAvailable: true, - fastModeEnabled: false, - }; - client.getState.mockResolvedValue(codexProviderState); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("codex"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - state: { - ...initial().state, - goalsSupported: true, - collaborationMode: "default", - collaborationModes: ["default", "plan"], - fastModeAvailable: true, - fastModeEnabled: false, - }, - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ - type: "prompt", - message: "/goal Ship parity", - }); - expect(client.updateGoal).toHaveBeenCalledWith("set", "Ship parity"); - expect(client.prompt).not.toHaveBeenCalled(); - - await runtime.command({ - type: "prompt", - message: "/plan", - }); - await runtime.command({ type: "set_fast_mode", enabled: true }); - expect(client.setCollaborationMode).toHaveBeenCalledWith("plan"); - expect(client.setFastMode).toHaveBeenCalledWith(true); - - await runtime.command({ - type: "implement_plan", - plan: "- Inspect\n- Implement", - }); - expect(client.setCollaborationMode).toHaveBeenLastCalledWith("default"); - expect(client.prompt).toHaveBeenCalledWith( - expect.stringContaining("The user approved the plan."), - ); - expect(client.prompt).toHaveBeenCalledWith( - expect.stringContaining("- Inspect\n- Implement"), - ); - await runtime.stop(); - }); - - it("runs OMP compaction out of band and refreshes session usage", async () => { - const client = new FakeAgentClient(); - const compactedStats = { - ...stats, - tokens: { - ...stats.tokens, - input: 27_603, - total: 27_603, - }, - contextUsage: { - tokens: 27_603, - contextWindow: 131_072, - percent: 21.06, - }, - }; - client.getSessionStats.mockResolvedValueOnce(compactedStats); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ - type: "prompt", - message: "/compact focus on tests", - }); - - expect(client.compact).toHaveBeenCalledWith("focus on tests"); - expect(client.prompt).not.toHaveBeenCalled(); - expect(client.getSessionStats).toHaveBeenCalledTimes(1); - expect(runtime.snapshot().stats).toEqual(compactedStats); - await runtime.stop(); - }); - - it("owns queued messages independently of the provider queue", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - const events: AgentRuntimeEnvelope[] = []; - const unsubscribe = runtime.subscribe((event) => events.push(event)); - - await runtime.command({ - type: "prompt", - message: "First", - }); - await runtime.command({ type: "queue", message: "Second" }); - expect(client.prompt).toHaveBeenCalledTimes(1); - expect(client.followUp).not.toHaveBeenCalled(); - expect(runtime.snapshot().queuedMessages).toEqual([ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ]); - expect(events).toContainEqual( - expect.objectContaining({ - type: "runtime_event", - data: { - type: "overtchat_queue_update", - queuedMessages: [ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ], - }, - }), - ); - - client.emit({ - type: "queue_update", - steering: ["Provider-owned"], - followUp: ["Provider-owned"], - }); - expect(runtime.snapshot().queuedMessages).toEqual([ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ]); - - client.emit({ type: "agent_settled" }); - await vi.waitFor(() => { - expect(client.prompt).toHaveBeenNthCalledWith(2, "Second"); - }); - expect(runtime.snapshot().queuedMessages).toEqual([]); - unsubscribe(); - await runtime.stop(); - }); - - it("preserves queued images and resolves them only when sent", async () => { - mocks.resolveAgentImages.mockClear(); - const client = new FakeAgentClient(); - const imageModel: AgentModel = { - ...model, - input: ["text", "image"], - }; - const imageRef = { - uploadId: "11111111-1111-4111-8111-111111111111", - filename: "screen.png", - mediaType: "image/png" as const, - }; - const resolvedImage = { - ...imageRef, - data: "aW1hZ2U=", - }; - mocks.resolveAgentImages.mockResolvedValueOnce([resolvedImage]); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - state: { - ...initial().state, - model: imageModel, - }, - models: [imageModel], - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ - type: "queue", - message: "", - images: [imageRef], - }); - - expect(mocks.resolveAgentImages).not.toHaveBeenCalled(); - expect(runtime.snapshot().queuedMessages).toEqual([ - { - id: "session:1", - message: "", - images: [imageRef], - status: "pending", - }, - ]); - - client.emit({ type: "agent_settled" }); - await vi.waitFor(() => { - expect(client.prompt).toHaveBeenNthCalledWith(2, "", [resolvedImage]); - }); - expect(mocks.resolveAgentImages).toHaveBeenCalledWith([imageRef], "user"); - expect(runtime.snapshot().queuedMessages).toEqual([]); - expect(runtime.snapshot().messages).toContainEqual( - expect.objectContaining({ - role: "user", - content: [ - { - type: "image", - url: `/api/uploads/${imageRef.uploadId}`, - mimeType: "image/png", - filename: "screen.png", - }, - ], - }), - ); - await runtime.stop(); - }); - - it("rejects image input when the selected model is text-only", async () => { - mocks.resolveAgentImages.mockClear(); - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await expect( - runtime.command({ - type: "prompt", - message: "Inspect this", - images: [ - { - uploadId: "11111111-1111-4111-8111-111111111111", - filename: "screen.png", - mediaType: "image/png", - }, - ], - }), - ).rejects.toThrow("does not support image input"); - expect(mocks.resolveAgentImages).not.toHaveBeenCalled(); - expect(client.prompt).not.toHaveBeenCalled(); - await runtime.stop(); - }); - - it("steers the active turn without aborting or duplicating an early provider echo", async () => { - const client = new FakeAgentClient(); - let acceptSteer: () => void = vi.fn(); - client.steer.mockImplementationOnce( - () => - new Promise((resolve) => { - acceptSteer = () => resolve({}); - }), - ); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - const steering = runtime.command({ - type: "steer", - message: "Focus on the failing test", - }); - client.emit({ - type: "message_end", - message: { - role: "user", - content: "Focus on the failing test", - timestamp: 20, - }, - }); - acceptSteer(); - await steering; - - expect(client.steer).toHaveBeenCalledWith( - "Focus on the failing test", - ); - expect(client.abort).not.toHaveBeenCalled(); - expect( - runtime - .snapshot() - .messages.filter( - (message) => - message && - typeof message === "object" && - Reflect.get(message, "content") === - "Focus on the failing test", - ), - ).toHaveLength(1); - await runtime.stop(); - }); - - it("does not turn a prompt into an implicit interrupt", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await expect( - runtime.command({ - type: "prompt", - message: "Send this now", - }), - ).rejects.toThrow( - "Pi is already working. Queue the message or steer the active turn.", - ); - - expect(client.abort).not.toHaveBeenCalled(); - expect(client.prompt).toHaveBeenNthCalledWith(1, "First"); - expect(client.steer).not.toHaveBeenCalled(); - expect(client.followUp).not.toHaveBeenCalled(); - await runtime.stop(); - }); - - it("steers with a selected queued message and preserves FIFO order", async () => { - const client = new FakeAgentClient(); - let acceptSteer: () => void = vi.fn(); - client.steer.mockImplementationOnce( - () => - new Promise((resolve) => { - acceptSteer = () => resolve({}); - }), - ); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - await runtime.command({ type: "queue", message: "Third" }); - const steering = runtime.command({ - type: "steer_queued_message", - id: "session:2", - }); - - expect(client.steer).toHaveBeenCalledWith("Third"); - expect(runtime.snapshot().queuedMessages).toEqual([ - { - id: "session:1", - message: "Second", - status: "pending", - }, - { - id: "session:2", - message: "Third", - status: "sending", - }, - ]); - acceptSteer(); - await steering; - - expect(client.abort).not.toHaveBeenCalled(); - expect(client.prompt).toHaveBeenNthCalledWith(1, "First"); - expect(runtime.snapshot().queuedMessages).toEqual([ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ]); - expect(runtime.snapshot().messages).toContainEqual( - expect.objectContaining({ - role: "user", - content: "Third", - overtchatSubmissionId: "session:2", - }), - ); - await runtime.stop(); - }); - - it("keeps a queued message pending when steering fails", async () => { - const client = new FakeAgentClient(); - client.steer.mockRejectedValueOnce( - new Error("Provider rejected the steering message"), - ); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - await expect( - runtime.command({ - type: "steer_queued_message", - id: "session:1", - }), - ).rejects.toThrow("Provider rejected the steering message"); - - expect(client.abort).not.toHaveBeenCalled(); - expect(client.prompt).toHaveBeenCalledTimes(1); - expect(runtime.snapshot()).toMatchObject({ - status: "running", - error: "Provider rejected the steering message", - queuedMessages: [ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ], - messages: [ - expect.objectContaining({ - role: "user", - content: "First", - }), - ], - }); - await runtime.stop(); - }); - - it("settles immediately after abort acknowledgement", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "abort" }); - - expect(client.abort).toHaveBeenCalledTimes(1); - expect(client.getState).not.toHaveBeenCalled(); - expect(runtime.snapshot().status).toBe("idle"); - expect(client.prompt).toHaveBeenCalledTimes(1); - await runtime.stop(); - }); - - it("keeps the active turn running when abort is rejected", async () => { - const client = new FakeAgentClient(); - client.abort.mockRejectedValueOnce(new Error("Abort rejected")); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - - await expect(runtime.command({ type: "abort" })).rejects.toThrow( - "Abort rejected", - ); - - expect(client.getState).not.toHaveBeenCalled(); - expect(client.prompt).toHaveBeenCalledTimes(1); - expect(runtime.snapshot()).toMatchObject({ - status: "running", - error: "Abort rejected", - queuedMessages: [ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ], - }); - await runtime.stop(); - }); - - it("resets runtime state when normal prompt submission fails", async () => { - const client = new FakeAgentClient(); - client.prompt.mockRejectedValueOnce(new Error("Prompt rejected")); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await expect( - runtime.command({ type: "prompt", message: "First" }), - ).rejects.toThrow("Prompt rejected"); - expect(runtime.snapshot().status).toBe("idle"); - expect(runtime.snapshot().queuedMessages).toEqual([]); - expect(runtime.snapshot().messages).toEqual([]); - expect(runtime.snapshot().error).toBe("Prompt rejected"); - await runtime.stop(); - }); - - it("settles provider idle even when transcript refresh fails", async () => { - const client = new FakeAgentClient(); - client.getMessages.mockRejectedValueOnce(new Error("History unavailable")); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - client.emit({ type: "agent_settled" }); - - await vi.waitFor(() => { - expect(runtime.snapshot().status).toBe("idle"); - }); - expect(client.prompt).toHaveBeenCalledTimes(1); - await runtime.stop(); - }); - - it("ignores OMP extension cycles without assistant output", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "omp-session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - expect(client.followUp).not.toHaveBeenCalled(); - client.emit({ - type: "agent_end", - messages: [], - willContinue: true, - }); - await Promise.resolve(); - expect(client.prompt).toHaveBeenCalledTimes(1); - expect(client.getState).not.toHaveBeenCalled(); - - client.emit({ - type: "agent_end", - messages: [{ role: "assistant", content: [] }], - }); - await vi.waitFor(() => { - expect(client.prompt).toHaveBeenNthCalledWith(2, "Second"); - }); - expect(runtime.snapshot().status).toBe("running"); - await runtime.stop(); - }); - - it("waits through OMP auto-compaction after assistant output", async () => { - const client = new FakeAgentClient(); - let resolveState: (state: typeof idleProviderState) => void = vi.fn(); - client.getState.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveState = resolve; - }), - ); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - expect(client.followUp).not.toHaveBeenCalled(); - client.emit({ - type: "agent_end", - messages: [{ role: "assistant", content: [] }], - isTerminal: false, - willContinue: true, - }); - - await vi.waitFor(() => { - expect(client.getState).toHaveBeenCalledTimes(1); - }); - expect(client.prompt).toHaveBeenCalledTimes(1); - - resolveState(idleProviderState); - await vi.waitFor(() => { - expect(client.prompt).toHaveBeenNthCalledWith(2, "Second"); - }); - expect(runtime.snapshot().status).toBe("running"); - await runtime.stop(); - }); - - it("stops polling and preserves the queue when OMP never becomes idle", async () => { - vi.useFakeTimers(); - const client = new FakeAgentClient(); - client.getState.mockResolvedValue({ - ...idleProviderState, - isStreaming: false, - isCompacting: true, - }); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - try { - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ - type: "queue", - message: "Second", - }); - client.emit({ - type: "agent_end", - messages: [{ role: "assistant", content: [] }], - }); - - await vi.advanceTimersByTimeAsync(30_000); - await Promise.resolve(); - - expect(runtime.snapshot()).toMatchObject({ - status: "running", - error: expect.stringContaining( - "still reports that it is working after 30 seconds", - ), - queuedMessages: [ - { - id: "session:1", - message: "Second", - status: "pending", - }, - ], - }); - expect(client.prompt).toHaveBeenCalledTimes(1); - } finally { - await runtime.stop(); - vi.useRealTimers(); - } - }); - - it("cancels provider-idle reconciliation when the runtime stops", async () => { - const client = new FakeAgentClient(); - let resolveState: (state: typeof idleProviderState) => void = vi.fn(); - client.getState.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveState = resolve; - }), - ); - const onExit = vi.fn(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - onExit, - ); - - await runtime.command({ type: "prompt", message: "First" }); - client.emit({ - type: "agent_end", - messages: [{ role: "assistant", content: [] }], - }); - await vi.waitFor(() => { - expect(client.getState).toHaveBeenCalledTimes(1); - }); - - await runtime.stop(); - resolveState({ - ...idleProviderState, - isStreaming: true, - }); - await Promise.resolve(); - - expect(runtime.snapshot().status).toBe("exited"); - expect(client.getState).toHaveBeenCalledTimes(1); - expect(onExit).toHaveBeenCalledTimes(1); - }); - - it("drains the OvertChat queue after Stop is acknowledged", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - await runtime.command({ type: "abort" }); - - expect(client.abort).toHaveBeenCalledTimes(1); - expect(client.getState).not.toHaveBeenCalled(); - expect(client.followUp).not.toHaveBeenCalled(); - await vi.waitFor(() => { - expect(client.prompt).toHaveBeenNthCalledWith(2, "Second"); - }); - expect(runtime.snapshot().queuedMessages).toEqual([]); - await runtime.stop(); - }); - - it("supersedes an in-flight provider idle poll when Stop is acknowledged", async () => { - const client = new FakeAgentClient(); - let resolveState: (state: typeof idleProviderState) => void = vi.fn(); - client.getState.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveState = resolve; - }), - ); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("omp"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - - await runtime.command({ type: "prompt", message: "First" }); - await runtime.command({ type: "queue", message: "Second" }); - client.emit({ - type: "agent_end", - messages: [{ role: "assistant", content: [] }], - willContinue: true, - }); - await vi.waitFor(() => { - expect(client.getState).toHaveBeenCalledTimes(1); - }); - - await runtime.command({ type: "abort" }); - - expect(client.abort).toHaveBeenCalledTimes(1); - await vi.waitFor(() => { - expect(client.prompt).toHaveBeenNthCalledWith(2, "Second"); - }); - expect(runtime.snapshot().status).toBe("running"); - - resolveState({ - ...idleProviderState, - isStreaming: true, - }); - await Promise.resolve(); - - expect(client.getState).toHaveBeenCalledTimes(1); - expect(runtime.snapshot().status).toBe("running"); - await runtime.stop(); - }); - - it("replays sequenced provider and OvertChat queue events", async () => { - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - const first: unknown[] = []; - const unsubscribeFirst = runtime.subscribe((event) => first.push(event)); - client.emit({ type: "turn_start" }); - await runtime.command({ - type: "queue", - message: "Summarize the result", - }); - expect(runtime.snapshot().queuedMessages).toEqual([ - { - id: "session:1", - message: "Summarize the result", - status: "pending", - }, - ]); - - const replayed: unknown[] = []; - const unsubscribeReplay = runtime.subscribe( - (event) => replayed.push(event), - 1, - ); - expect(replayed).toEqual([ - expect.objectContaining({ sequence: 2, type: "runtime_event" }), - expect.objectContaining({ - sequence: 3, - type: "runtime_event", - data: expect.objectContaining({ - type: "overtchat_queue_update", - }), - }), - ]); - - client.emit({ - type: "interaction_request", - id: "question", - method: "confirm", - title: "Continue?", - }); - expect(runtime.snapshot().pendingInteraction).toMatchObject({ - id: "question", - }); - - client.respondToInteraction.mockImplementationOnce(() => { - client.emit({ - type: "interaction_request", - id: "question", - method: "input", - title: "Follow-up", - }); - }); - await runtime.command({ - type: "interaction_response", - id: "question", - confirmed: true, - }); - expect(client.respondToInteraction).toHaveBeenCalledWith("question", { - confirmed: true, - }); - expect(runtime.snapshot().pendingInteraction).toMatchObject({ - id: "question", - title: "Follow-up", - }); - client.emit({ type: "interaction_resolved", id: "question" }); - expect(runtime.snapshot().pendingInteraction).toBeUndefined(); - unsubscribeFirst(); - unsubscribeReplay(); - await runtime.stop(); - }); - - it("persists provider-native history forks as new sessions", async () => { - vi.clearAllMocks(); - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - mocks.upsertAgentSession.mockResolvedValue({ - ...owned().agentSession, - id: "forked-session", - providerSessionId: "native-fork", - providerSessionPath: "/sessions/native-fork.jsonl", - }); - const registry = new AgentRuntimeRegistry(); - - await expect( - registry.fork(owned(), runtime, { - type: "edit_message", - messageId: "user-message", - }), - ).resolves.toEqual({ - sessionId: "forked-session", - draft: "Edit this prompt", - }); - expect(client.forkSession).toHaveBeenCalledWith( - "user-message", - "edit", - ); - expect(mocks.upsertAgentSession).toHaveBeenCalledWith("workspace", { - providerSessionId: "native-fork", - providerSessionPath: "/sessions/native-fork.jsonl", - name: null, - firstMessage: "Earlier prompt", - messageCount: 2, - createdAt: new Date(1_000), - modifiedAt: new Date(2_000), - }); - await runtime.stop(); - }); - - it("discards a native fork when local session persistence fails", async () => { - vi.clearAllMocks(); - const client = new FakeAgentClient(); - const runtime = new AgentSessionRuntime( - "session", - "user", - "connection", - "workspace", - agentProviderAdapter("pi"), - client as unknown as AgentRuntimeClient, - { - ...initial(), - thinkingLevels: [...initial().thinkingLevels], - }, - vi.fn(), - ); - mocks.upsertAgentSession.mockRejectedValueOnce( - new Error("database unavailable"), - ); - const registry = new AgentRuntimeRegistry(); - - await expect( - registry.fork(owned(), runtime, { - type: "fork_message", - messageId: "assistant-message", - }), - ).rejects.toThrow("database unavailable"); - expect(client.discardForkedSession).toHaveBeenCalledWith( - expect.objectContaining({ providerSessionId: "native-fork" }), - ); - await runtime.stop(); - }); - - it("deduplicates concurrent starts and stops matching owners", async () => { - vi.clearAllMocks(); - const client = new FakeAgentClient(); - mocks.startPiRpc.mockReturnValue(client); - const registry = new AgentRuntimeRegistry(); - const record = owned(); - - const [first, second] = await Promise.all([ - registry.getOrStart(record), - registry.getOrStart(record), - ]); - - expect(first).toBe(second); - expect(mocks.startPiRpc).toHaveBeenCalledTimes(1); - expect(registry.runtimeStatusForSession("session", "user")).toBe("idle"); - expect(registry.runtimeStatusForSession("session", "another-user")).toBe( - "idle", - ); - client.emit({ type: "turn_start" }); - expect(registry.runtimeStatusForSession("session", "user")).toBe( - "running", - ); - await registry.stopWorkspace("workspace", "user"); - expect(client.stop).toHaveBeenCalledTimes(1); - - const secondClient = new FakeAgentClient(); - mocks.startPiRpc.mockReturnValue(secondClient); - await registry.getOrStart(record); - await registry.stopUser("user"); - expect(secondClient.stop).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/web/lib/agents/runtime/target.ts b/apps/web/lib/agents/runtime/target.ts deleted file mode 100644 index dec1b013..00000000 --- a/apps/web/lib/agents/runtime/target.ts +++ /dev/null @@ -1,24 +0,0 @@ -import "server-only"; -import type { ConnectorShellMode } from "@overtchat/agent-bridge"; -import type { AgentHostRow } from "@/lib/db/agentConnections"; -import type { HostTarget } from "@/lib/agents/runtime/process"; - -export function targetForStoredHost( - host: AgentHostRow, - shellMode: ConnectorShellMode, -): HostTarget { - if (host.transport === "local") { - return { - connectorId: host.connectorId, - transport: "local", - shellMode, - }; - } - if (!host.sshAlias) throw new Error("The saved SSH alias is missing."); - return { - connectorId: host.connectorId, - transport: "ssh", - alias: host.sshAlias, - shellMode, - }; -} diff --git a/apps/web/lib/agents/sidebar.test.ts b/apps/web/lib/agents/sidebar.test.ts index 3c48c7e5..851e7c7e 100644 --- a/apps/web/lib/agents/sidebar.test.ts +++ b/apps/web/lib/agents/sidebar.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { AgentConnectionListItem, AgentSessionListItem, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { AGENT_SESSION_PREVIEW_COUNT, agentConnectionHasRunningSession, diff --git a/apps/web/lib/agents/sidebar.ts b/apps/web/lib/agents/sidebar.ts index 1572c88d..62e1f1bb 100644 --- a/apps/web/lib/agents/sidebar.ts +++ b/apps/web/lib/agents/sidebar.ts @@ -2,7 +2,7 @@ import type { AgentConnectionListItem, AgentSessionListItem, AgentWorkspaceListItem, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; export const AGENT_SESSION_PREVIEW_COUNT = 8; diff --git a/apps/web/lib/db/agentConnections.test.ts b/apps/web/lib/db/agentConnections.test.ts index 71a49428..e4a071dd 100644 --- a/apps/web/lib/db/agentConnections.test.ts +++ b/apps/web/lib/db/agentConnections.test.ts @@ -16,7 +16,9 @@ const raw = new Database(databasePath); raw.pragma("foreign_keys = ON"); raw.exec(` CREATE TABLE user ( - id TEXT PRIMARY KEY NOT NULL + id TEXT PRIMARY KEY NOT NULL, + role TEXT DEFAULT 'admin', + banned INTEGER DEFAULT 0 ); CREATE TABLE host_connectors ( id TEXT PRIMARY KEY NOT NULL, @@ -271,4 +273,38 @@ describe("agent connection persistence", () => { raw.prepare("SELECT count(*) AS count FROM agent_workspaces").get(), ).toEqual({ count: 0 }); }); + + it("reconciles only sessions still authorized for a connector", async () => { + const owned = createAliceConnection(); + const workspace = await repository.createAgentWorkspace( + owned.connection.id, + "alice", + { path: "/work/overtchat", name: "overtchat" }, + ); + const agentSession = await repository.upsertAgentSession(workspace!.id, { + providerSessionId: "session", + providerSessionPath: "/remote/session.jsonl", + name: null, + firstMessage: null, + messageCount: 0, + createdAt: null, + modifiedAt: null, + }); + + await expect( + repository.listActiveAgentSessionIds("alice-connector"), + ).resolves.toEqual([agentSession.id]); + + raw.prepare("UPDATE user SET role = 'user' WHERE id = 'alice'").run(); + await expect( + repository.listActiveAgentSessionIds("alice-connector"), + ).resolves.toEqual([]); + + raw.prepare( + "UPDATE user SET role = 'admin', banned = 1 WHERE id = 'alice'", + ).run(); + await expect( + repository.listActiveAgentSessionIds("alice-connector"), + ).resolves.toEqual([]); + }); }); diff --git a/apps/web/lib/db/agentConnections.ts b/apps/web/lib/db/agentConnections.ts index a5336595..aa42750a 100644 --- a/apps/web/lib/db/agentConnections.ts +++ b/apps/web/lib/db/agentConnections.ts @@ -6,6 +6,7 @@ import { agentHosts, agentSessions, agentWorkspaces, + user, } from "@/lib/db/schema"; import type { AgentConnectionListItem, @@ -13,7 +14,7 @@ import type { AgentProviderId, AgentSessionListItem, AgentTransportId, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import type { ConnectorShellMode } from "@overtchat/agent-bridge"; export type AgentHostRow = typeof agentHosts.$inferSelect; @@ -132,6 +133,32 @@ export async function listAgentConnections( })); } +export async function listActiveAgentSessionIds( + connectorId: string, +): Promise { + const rows = await db + .select({ id: agentSessions.id }) + .from(agentSessions) + .innerJoin( + agentWorkspaces, + eq(agentSessions.workspaceId, agentWorkspaces.id), + ) + .innerJoin( + agentConnections, + eq(agentWorkspaces.connectionId, agentConnections.id), + ) + .innerJoin(agentHosts, eq(agentConnections.hostId, agentHosts.id)) + .innerJoin(user, eq(agentHosts.userId, user.id)) + .where( + and( + eq(agentHosts.connectorId, connectorId), + eq(user.role, "admin"), + eq(user.banned, false), + ), + ); + return rows.map((row) => row.id); +} + export async function getOwnedAgentConnection( id: string, userId: string, @@ -266,13 +293,14 @@ export async function createAgentWorkspace( connectionId: string, userId: string, input: { path: string; name: string }, + workspaceId = crypto.randomUUID(), ): Promise { const owned = await getOwnedAgentConnection(connectionId, userId); if (!owned) return null; const [row] = await db .insert(agentWorkspaces) .values({ - id: crypto.randomUUID(), + id: workspaceId, connectionId, path: input.path, name: input.name, @@ -362,12 +390,13 @@ export function syncAgentWorkspaceSessions( export async function upsertAgentSession( workspaceId: string, session: ProviderSessionMetadata, + sessionId = crypto.randomUUID(), ): Promise { const now = new Date(); const [row] = await db .insert(agentSessions) .values({ - id: crypto.randomUUID(), + id: sessionId, workspaceId, providerSessionId: session.providerSessionId, providerSessionPath: session.providerSessionPath, diff --git a/apps/web/lib/queries/agentConnections.ts b/apps/web/lib/queries/agentConnections.ts index 92f9128e..f46e9e8d 100644 --- a/apps/web/lib/queries/agentConnections.ts +++ b/apps/web/lib/queries/agentConnections.ts @@ -14,7 +14,7 @@ import type { DetectedAgentInstallation, HostConnectorListItem, HostConnectorPairing, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { agentConnectionKeys } from "@/lib/queries/keys"; import { agentConnectionHasRunningSession } from "@/lib/agents/sidebar"; diff --git a/apps/web/lib/queries/agentSessions.ts b/apps/web/lib/queries/agentSessions.ts index 8a555e17..4a4fb6aa 100644 --- a/apps/web/lib/queries/agentSessions.ts +++ b/apps/web/lib/queries/agentSessions.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AgentQueuedMessage, @@ -8,8 +8,8 @@ import type { AgentRuntimeSnapshot, AgentSessionCommand, AgentUsageSnapshot, -} from "@/lib/agents/types"; -import { applyAgentRuntimeEnvelope } from "@/lib/agents/runtime/state"; +} from "@overtchat/agent-bridge"; +import { applyAgentRuntimeEnvelope } from "@overtchat/agent-bridge"; import { agentConnectionKeys, agentSessionKeys, @@ -31,6 +31,7 @@ async function fetchAgentSession(id: string): Promise { export function useAgentSession(id: string) { const queryClient = useQueryClient(); + const cursor = useRef<{ epoch: string; sequence: number } | null>(null); const [streamStatus, setStreamStatus] = useState< "connecting" | "connected" | "reconnecting" >("connecting"); @@ -42,6 +43,7 @@ export function useAgentSession(id: string) { useEffect(() => { if (!query.isSuccess) return; + cursor.current = null; const events = new EventSource(`/api/agent-sessions/${id}/events`); events.onopen = () => setStreamStatus("connected"); events.onerror = () => setStreamStatus("reconnecting"); @@ -57,6 +59,27 @@ export function useAgentSession(id: string) { }); return; } + const previous = cursor.current; + if ( + previous?.epoch === envelope.epoch && + envelope.sequence <= previous.sequence + ) { + return; + } + if ( + envelope.type !== "snapshot" && + previous?.epoch === envelope.epoch && + envelope.sequence > previous.sequence + 1 + ) { + void queryClient.invalidateQueries({ + queryKey: agentSessionKeys.detail(id), + }); + return; + } + cursor.current = { + epoch: envelope.epoch, + sequence: envelope.sequence, + }; queryClient.setQueryData( agentSessionKeys.detail(id), (current) => applyAgentRuntimeEnvelope(current, envelope), @@ -84,10 +107,18 @@ export function useAgentSessionCommand(id: string) { queuedMessages?: AgentQueuedMessage[]; usage?: AgentUsageSnapshot; }> => { + const wireCommand = + (command.type === "prompt" || + command.type === "steer" || + command.type === "queue" || + command.type === "implement_plan") && + !command.clientMessageId + ? { ...command, clientMessageId: crypto.randomUUID() } + : command; const response = await fetch(`/api/agent-sessions/${id}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(command), + body: JSON.stringify(wireCommand), }); if (!response.ok) throw await responseError(response); return (await response.json()) as { diff --git a/apps/web/lib/queries/agentWorkspaces.ts b/apps/web/lib/queries/agentWorkspaces.ts index e1c7f05c..e6971c6a 100644 --- a/apps/web/lib/queries/agentWorkspaces.ts +++ b/apps/web/lib/queries/agentWorkspaces.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { AgentWorkspaceGitStatus } from "@/lib/agents/types"; +import type { AgentWorkspaceGitStatus } from "@overtchat/agent-bridge"; import { agentWorkspaceKeys } from "@/lib/queries/keys"; async function responseError(response: Response): Promise { diff --git a/apps/web/package.json b/apps/web/package.json index 79e51246..e9c09ad3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -67,7 +67,6 @@ "tw-animate-css": "^1.4.0", "unpdf": "^1.6.2", "use-stick-to-bottom": "^1.1.6", - "ws": "^8.21.3", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.4.3" }, @@ -78,7 +77,6 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", "@vitest/ui": "^4.1.6", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", diff --git a/package-lock.json b/package-lock.json index 310a9cca..aad10133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,10 @@ }, "apps/connector": { "name": "@overtchat/connector", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { - "@overtchat/agent-bridge": "*" + "@overtchat/agent-bridge": "*", + "@overtchat/agent-runtime": "*" }, "bin": { "overtchat-connector": "dist/overtchat-connector.mjs" @@ -440,7 +441,7 @@ "@octokit/rest": "^22.0.1", "@overtchat/shared": "*", "lucide-react": "^1.14.0", - "next": "^16.3.0", + "next": "16.3.0", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -1347,7 +1348,7 @@ "lucide-react": "^1.14.0", "mammoth": "^1.12.0", "media-chrome": "^4.19.0", - "next": "^16.3.0", + "next": "16.3.0", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -1364,7 +1365,6 @@ "tw-animate-css": "^1.4.0", "unpdf": "^1.6.2", "use-stick-to-bottom": "^1.1.6", - "ws": "^8.21.3", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.4.3" }, @@ -1375,7 +1375,6 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", "@vitest/ui": "^4.1.6", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", @@ -7072,6 +7071,10 @@ "resolved": "packages/agent-bridge", "link": true }, + "node_modules/@overtchat/agent-runtime": { + "resolved": "packages/agent-runtime", + "link": true + }, "node_modules/@overtchat/connector": { "resolved": "apps/connector", "link": true @@ -27561,9 +27564,13 @@ "packages/agent-bridge": { "name": "@overtchat/agent-bridge", "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@types/node": "^22", - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "vitest": "^4.1.6" } }, "packages/agent-bridge/node_modules/@types/node": { @@ -27576,6 +27583,31 @@ "undici-types": "~6.21.0" } }, + "packages/agent-runtime": { + "name": "@overtchat/agent-runtime", + "version": "0.1.0", + "dependencies": { + "@overtchat/agent-bridge": "*", + "ws": "^8.21.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22", + "@types/ws": "^8.18.1", + "typescript": "~6.0.3", + "vitest": "^4.1.6" + } + }, + "packages/agent-runtime/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "packages/shared": { "name": "@overtchat/shared", "version": "0.1.0", diff --git a/packages/agent-bridge/package.json b/packages/agent-bridge/package.json index c8d3c00f..3fbd4560 100644 --- a/packages/agent-bridge/package.json +++ b/packages/agent-bridge/package.json @@ -9,10 +9,15 @@ ".": "./src/index.ts" }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22", - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "vitest": "^4.1.6" } } diff --git a/apps/web/lib/agents/types.ts b/packages/agent-bridge/src/agents.ts similarity index 96% rename from apps/web/lib/agents/types.ts rename to packages/agent-bridge/src/agents.ts index ad0ed889..07d29f30 100644 --- a/apps/web/lib/agents/types.ts +++ b/packages/agent-bridge/src/agents.ts @@ -1,5 +1,7 @@ import { z } from "zod"; -import type { ConnectorShellMode } from "@overtchat/agent-bridge"; + +export const CONNECTOR_SHELL_MODES = ["interactive", "login"] as const; +export type ConnectorShellMode = (typeof CONNECTOR_SHELL_MODES)[number]; export const AGENT_PROVIDER_IDS = ["pi", "omp", "codex"] as const; export type AgentProviderId = (typeof AGENT_PROVIDER_IDS)[number]; @@ -308,22 +310,27 @@ export type AgentUsageSnapshot = { unavailableReason: string | null; }; +const clientMessageIdSchema = z.string().min(1).max(500).optional(); + export const agentSessionCommandSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("prompt"), message: z.string().trim().max(200_000), images: z.array(agentPromptImageSchema).max(MAX_AGENT_IMAGES).optional(), + clientMessageId: clientMessageIdSchema, }), z.object({ type: z.literal("abort") }), z.object({ type: z.literal("steer"), message: z.string().trim().max(200_000), images: z.array(agentPromptImageSchema).max(MAX_AGENT_IMAGES).optional(), + clientMessageId: clientMessageIdSchema, }), z.object({ type: z.literal("queue"), message: z.string().trim().max(200_000), images: z.array(agentPromptImageSchema).max(MAX_AGENT_IMAGES).optional(), + clientMessageId: clientMessageIdSchema, }), z.object({ type: z.literal("remove_queued_message"), @@ -358,6 +365,7 @@ export const agentSessionCommandSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("implement_plan"), plan: z.string().trim().max(100_000), + clientMessageId: clientMessageIdSchema, }), z.object({ type: z.literal("compact"), @@ -457,11 +465,13 @@ export type AgentRuntimeSnapshot = { export type AgentRuntimeEnvelope = | { + epoch: string; sequence: number; type: "snapshot"; data: AgentRuntimeSnapshot; } | { + epoch: string; sequence: number; type: "runtime_event"; data: { diff --git a/apps/web/lib/agents/catalog.ts b/packages/agent-bridge/src/catalog.ts similarity index 92% rename from apps/web/lib/agents/catalog.ts rename to packages/agent-bridge/src/catalog.ts index 68282ea1..618f5c39 100644 --- a/apps/web/lib/agents/catalog.ts +++ b/packages/agent-bridge/src/catalog.ts @@ -1,8 +1,8 @@ import type { AgentProviderId, AgentRuntimeCapabilities, -} from "@/lib/agents/types"; -import { AGENT_PROVIDER_IDS } from "@/lib/agents/types"; +} from "./agents"; +import { AGENT_PROVIDER_IDS } from "./agents"; export type AgentProviderMetadata = { id: AgentProviderId; diff --git a/apps/web/lib/agents/runtime/commands.test.ts b/packages/agent-bridge/src/commands.test.ts similarity index 99% rename from apps/web/lib/agents/runtime/commands.test.ts rename to packages/agent-bridge/src/commands.test.ts index 1ed55a29..9438e5a1 100644 --- a/apps/web/lib/agents/runtime/commands.test.ts +++ b/packages/agent-bridge/src/commands.test.ts @@ -4,7 +4,7 @@ import { buildAgentPromptCommand, mergeAgentSlashCommands, normalizeAgentSessionCommand, -} from "./commands"; +} from "./commands.js"; const BUILTIN_COMMANDS = [ { diff --git a/apps/web/lib/agents/runtime/commands.ts b/packages/agent-bridge/src/commands.ts similarity index 99% rename from apps/web/lib/agents/runtime/commands.ts rename to packages/agent-bridge/src/commands.ts index eaa579c7..25d6e8a7 100644 --- a/apps/web/lib/agents/runtime/commands.ts +++ b/packages/agent-bridge/src/commands.ts @@ -2,7 +2,7 @@ import type { AgentPromptImage, AgentSessionCommand, AgentSlashCommand, -} from "@/lib/agents/types"; +} from "./agents"; export function mergeAgentSlashCommands( builtins: readonly AgentSlashCommand[], diff --git a/packages/agent-bridge/src/index.ts b/packages/agent-bridge/src/index.ts index 251c1fab..9d566ff3 100644 --- a/packages/agent-bridge/src/index.ts +++ b/packages/agent-bridge/src/index.ts @@ -1,23 +1,32 @@ -export const HOST_CONNECTOR_PROTOCOL_MIN_VERSION = 1; +import type { + AgentConnectionDraft, + AgentDiscoveryTarget, + AgentProviderId, + AgentRuntimeEnvelope, + AgentSessionCommand, + ConnectorShellMode, +} from "./agents"; +import { + CONNECTOR_SHELL_MODES, + AGENT_PROVIDER_IDS, + agentConnectionDraftSchema, + agentDiscoveryTargetSchema, + agentSessionCommandSchema, +} from "./agents"; + export const HOST_CONNECTOR_PROTOCOL_VERSION = 1; -export const HOST_CONNECTOR_RELEASE_VERSION = "0.1.0"; +export const HOST_CONNECTOR_RELEASE_VERSION = "0.2.0"; export const HOST_CONNECTOR_EVENT_BATCH_LIMIT = 256; -export const CONNECTOR_SHELL_MODES = ["interactive", "login"] as const; -export type ConnectorShellMode = (typeof CONNECTOR_SHELL_MODES)[number]; +export * from "./agents"; +export * from "./catalog"; +export * from "./commands"; +export * from "./state"; export type ConnectorTarget = | { transport: "local" } | { transport: "ssh"; alias: string }; -export type ConnectorProcessLaunch = { - command: string; - args?: string[]; - cwd?: string; - env?: Record; - shellMode: ConnectorShellMode; -}; - export type ConnectorSshHost = { alias: string; hostname: string; @@ -25,38 +34,75 @@ export type ConnectorSshHost = { username: string; }; -export type HostConnectorCommand = +export type AgentDaemonTarget = ConnectorTarget & { + shellMode?: ConnectorShellMode; +}; + +export type AgentDaemonWorkspaceDescriptor = { + connectionId: string; + workspaceId: string; + provider: AgentProviderId; + target: AgentDaemonTarget; + executable: string; + cwd: string; + detectedVersion?: string | null; +}; + +export type AgentDaemonSessionDescriptor = AgentDaemonWorkspaceDescriptor & { + sessionId: string; + providerSessionId: string; + providerSessionPath: string; +}; + +export type AgentDaemonRequest = + | { type: "list_ssh_hosts" } + | { type: "discover"; target: AgentDiscoveryTarget } + | { type: "probe"; draft: AgentConnectionDraft } | { - type: "sync"; - processIds: string[]; + type: "list_sessions"; + workspace: AgentDaemonWorkspaceDescriptor; } + | { type: "list_directories"; target: AgentDaemonTarget; path?: string } + | { type: "probe_workspace"; target: AgentDaemonTarget; path: string } + | { type: "git_status"; target: AgentDaemonTarget; path: string } | { - type: "spawn"; - processId: string; - target: ConnectorTarget; - launch: ConnectorProcessLaunch; + type: "create_session"; + sessionId: string; + workspace: AgentDaemonWorkspaceDescriptor; } + | { type: "open_session"; session: AgentDaemonSessionDescriptor } | { - type: "stdin"; - processId: string; - data: string; + type: "session_command"; + commandId: string; + clientMessageId?: string; + session: AgentDaemonSessionDescriptor; + command: AgentSessionCommand; } | { - type: "stdin_end"; - processId: string; + type: "subscribe_session"; + subscriptionId: string; + session: AgentDaemonSessionDescriptor; + after?: { epoch: string; sequence: number }; } + | { type: "unsubscribe_session"; subscriptionId: string } + | { type: "stop_session"; sessionId: string } + | { type: "stop_workspace"; workspaceId: string } + | { type: "stop_connection"; connectionId: string } + | { type: "stop_all" }; + +export type HostConnectorCommand = | { - type: "kill"; - processId: string; - signal: NodeJS.Signals; + type: "sync"; + connectionEpoch: string; + activeSessionIds: string[]; } | { type: "request"; requestId: string; - request: { type: "list_ssh_hosts" }; + request: AgentDaemonRequest; }; -export type HostConnectorEvent = +export type HostConnectorEventPayload = | { type: "response"; requestId: string; @@ -70,23 +116,38 @@ export type HostConnectorEvent = error: string; } | { - type: "stdout" | "stderr"; - processId: string; - data: string; + type: "session_event"; + subscriptionId: string; + sessionId: string; + envelope: AgentRuntimeEnvelope; } | { - type: "exit"; - processId: string; - code: number | null; - signal: NodeJS.Signals | null; - error?: string; + type: "session_metadata"; + sessionId: string; + patch: { + name?: string | null; + firstMessage?: string | null; + messageCount?: number; + providerModifiedAt?: number; + }; }; +export type HostConnectorEvent = { + sequence: number; + payload: HostConnectorEventPayload; +}; + export type HostConnectorEventBatch = { - protocolVersion: number; + protocolVersion: 1; + connectorEpoch: string; events: HostConnectorEvent[]; }; +export type HostConnectorEventAck = { + connectorEpoch: string; + acknowledgedSequence: number; +}; + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -119,118 +180,136 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === "string"); -} - -function isStringRecord(value: unknown): value is Record { +function isConnectorTarget(value: unknown): value is ConnectorTarget { + if (!isRecord(value)) return false; return ( - isRecord(value) && - Object.entries(value).every( - ([key, item]) => - /^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) && typeof item === "string", - ) + value.transport === "local" || + (value.transport === "ssh" && isNonEmptyString(value.alias)) ); } -const CONNECTOR_SIGNALS = new Set([ - "SIGABRT", - "SIGALRM", - "SIGBUS", - "SIGCHLD", - "SIGCONT", - "SIGFPE", - "SIGHUP", - "SIGILL", - "SIGINT", - "SIGIO", - "SIGIOT", - "SIGKILL", - "SIGPIPE", - "SIGPOLL", - "SIGPROF", - "SIGPWR", - "SIGQUIT", - "SIGSEGV", - "SIGSTKFLT", - "SIGSTOP", - "SIGSYS", - "SIGTERM", - "SIGTRAP", - "SIGTSTP", - "SIGTTIN", - "SIGTTOU", - "SIGURG", - "SIGUSR1", - "SIGUSR2", - "SIGVTALRM", - "SIGWINCH", - "SIGXCPU", - "SIGXFSZ", -]); +export function isHostConnectorProtocolVersion( + value: unknown, +): value is number { + return value === HOST_CONNECTOR_PROTOCOL_VERSION; +} -function isConnectorTarget(value: unknown): value is ConnectorTarget { - if (!isRecord(value)) return false; +function isAgentDaemonTarget(value: unknown): value is AgentDaemonTarget { return ( - value.transport === "local" || - (value.transport === "ssh" && isNonEmptyString(value.alias)) + isRecord(value) && + isConnectorTarget(value) && + (Reflect.get(value, "shellMode") === undefined || + (typeof Reflect.get(value, "shellMode") === "string" && + CONNECTOR_SHELL_MODES.includes( + Reflect.get(value, "shellMode") as ConnectorShellMode, + ))) ); } -function isConnectorProcessLaunch( +export function isAgentDaemonWorkspaceDescriptor( value: unknown, -): value is ConnectorProcessLaunch { - if (!isRecord(value) || !isNonEmptyString(value.command)) return false; +): value is AgentDaemonWorkspaceDescriptor { return ( - (value.args === undefined || isStringArray(value.args)) && - (value.cwd === undefined || typeof value.cwd === "string") && - (value.env === undefined || isStringRecord(value.env)) && - typeof value.shellMode === "string" && - CONNECTOR_SHELL_MODES.includes(value.shellMode as ConnectorShellMode) + isRecord(value) && + isNonEmptyString(value.connectionId) && + isNonEmptyString(value.workspaceId) && + (AGENT_PROVIDER_IDS as readonly unknown[]).includes(value.provider) && + isAgentDaemonTarget(value.target) && + isNonEmptyString(value.executable) && + typeof value.cwd === "string" && + (value.detectedVersion === undefined || + value.detectedVersion === null || + typeof value.detectedVersion === "string") ); } -export function isHostConnectorProtocolVersion( +export function isAgentDaemonSessionDescriptor( value: unknown, -): value is number { +): value is AgentDaemonSessionDescriptor { return ( - typeof value === "number" && - Number.isInteger(value) && - value >= HOST_CONNECTOR_PROTOCOL_MIN_VERSION && - value <= HOST_CONNECTOR_PROTOCOL_VERSION + isRecord(value) && + isAgentDaemonWorkspaceDescriptor(value) && + isNonEmptyString(Reflect.get(value, "sessionId")) && + isNonEmptyString(Reflect.get(value, "providerSessionId")) && + isNonEmptyString(Reflect.get(value, "providerSessionPath")) ); } +function isAgentDaemonRequest(value: unknown): value is AgentDaemonRequest { + if (!isRecord(value) || typeof value.type !== "string") return false; + switch (value.type) { + case "list_ssh_hosts": + return true; + case "discover": + return agentDiscoveryTargetSchema.safeParse(value.target).success; + case "probe": + return agentConnectionDraftSchema.safeParse(value.draft).success; + case "list_sessions": + return isAgentDaemonWorkspaceDescriptor(value.workspace); + case "list_directories": + return ( + isAgentDaemonTarget(value.target) && + (value.path === undefined || typeof value.path === "string") + ); + case "probe_workspace": + return isAgentDaemonTarget(value.target) && typeof value.path === "string"; + case "git_status": + return isAgentDaemonTarget(value.target) && typeof value.path === "string"; + case "create_session": + return ( + isNonEmptyString(value.sessionId) && + isAgentDaemonWorkspaceDescriptor(value.workspace) + ); + case "open_session": + return isAgentDaemonSessionDescriptor(value.session); + case "session_command": + return ( + isNonEmptyString(value.commandId) && + (value.clientMessageId === undefined || + isNonEmptyString(value.clientMessageId)) && + isAgentDaemonSessionDescriptor(value.session) && + agentSessionCommandSchema.safeParse(value.command).success + ); + case "subscribe_session": + return ( + isNonEmptyString(value.subscriptionId) && + isAgentDaemonSessionDescriptor(value.session) && + (value.after === undefined || + (isRecord(value.after) && + isNonEmptyString(value.after.epoch) && + Number.isSafeInteger(value.after.sequence) && + Number(value.after.sequence) >= 0)) + ); + case "unsubscribe_session": + return isNonEmptyString(value.subscriptionId); + case "stop_session": + return isNonEmptyString(value.sessionId); + case "stop_workspace": + return isNonEmptyString(value.workspaceId); + case "stop_connection": + return isNonEmptyString(value.connectionId); + case "stop_all": + return true; + default: + return false; + } +} + export function isHostConnectorCommand( value: unknown, ): value is HostConnectorCommand { if (!isRecord(value)) return false; switch (value.type) { case "sync": - return isStringArray(value.processIds); - case "spawn": return ( - isNonEmptyString(value.processId) && - isConnectorTarget(value.target) && - isConnectorProcessLaunch(value.launch) - ); - case "stdin": - return ( - isNonEmptyString(value.processId) && typeof value.data === "string" - ); - case "stdin_end": - return isNonEmptyString(value.processId); - case "kill": - return ( - isNonEmptyString(value.processId) && - typeof value.signal === "string" && - CONNECTOR_SIGNALS.has(value.signal as NodeJS.Signals) + isNonEmptyString(value.connectionEpoch) && + Array.isArray(value.activeSessionIds) && + value.activeSessionIds.every(isNonEmptyString) ); case "request": return ( isNonEmptyString(value.requestId) && - isRecord(value.request) && - value.request.type === "list_ssh_hosts" + isAgentDaemonRequest(value.request) ); default: return false; @@ -253,28 +332,70 @@ export function isConnectorSshHost(value: unknown): value is ConnectorSshHost { export function isHostConnectorEvent( value: unknown, ): value is HostConnectorEvent { - if (!isRecord(value) || typeof value.type !== "string") return false; - if (value.type === "response") { - return ( - typeof value.requestId === "string" && - ((value.success === true && "data" in value) || - (value.success === false && typeof value.error === "string")) - ); - } if ( - typeof value.processId !== "string" || - value.processId.length === 0 + !isRecord(value) || + !Number.isSafeInteger(value.sequence) || + Number(value.sequence) < 1 || + !isRecord(value.payload) || + typeof value.payload.type !== "string" ) { return false; } - if (value.type === "stdout" || value.type === "stderr") { - return typeof value.data === "string"; + const payload = value.payload; + if (payload.type === "response") { + return ( + isNonEmptyString(payload.requestId) && + ((payload.success === true && "data" in payload) || + (payload.success === false && typeof payload.error === "string")) + ); + } + if (payload.type === "session_event") { + if ( + !( + isNonEmptyString(payload.subscriptionId) && + isNonEmptyString(payload.sessionId) && + isRecord(payload.envelope) && + isNonEmptyString(payload.envelope.epoch) && + Number.isSafeInteger(payload.envelope.sequence) && + Number(payload.envelope.sequence) >= 1 && + ["snapshot", "runtime_event"].includes(String(payload.envelope.type)) && + "data" in payload.envelope + ) || + !isRecord(payload.envelope.data) + ) { + return false; + } + return payload.envelope.type === "snapshot" + ? payload.envelope.data.sessionId === payload.sessionId && + ["idle", "running", "exited"].includes( + String(payload.envelope.data.status), + ) + : isNonEmptyString(payload.envelope.data.type); } - if (value.type === "exit") { + if (payload.type === "session_metadata") { + if (!isNonEmptyString(payload.sessionId) || !isRecord(payload.patch)) { + return false; + } + const allowed = new Set([ + "name", + "firstMessage", + "messageCount", + "providerModifiedAt", + ]); return ( - (value.code === null || typeof value.code === "number") && - (value.signal === null || typeof value.signal === "string") && - (value.error === undefined || typeof value.error === "string") + Object.keys(payload.patch).every((key) => allowed.has(key)) && + (payload.patch.name === undefined || + payload.patch.name === null || + typeof payload.patch.name === "string") && + (payload.patch.firstMessage === undefined || + payload.patch.firstMessage === null || + typeof payload.patch.firstMessage === "string") && + (payload.patch.messageCount === undefined || + (Number.isSafeInteger(payload.patch.messageCount) && + Number(payload.patch.messageCount) >= 0)) && + (payload.patch.providerModifiedAt === undefined || + (typeof payload.patch.providerModifiedAt === "number" && + Number.isFinite(payload.patch.providerModifiedAt))) ); } return false; diff --git a/apps/web/lib/agents/runtime/state.test.ts b/packages/agent-bridge/src/state.test.ts similarity index 98% rename from apps/web/lib/agents/runtime/state.test.ts rename to packages/agent-bridge/src/state.test.ts index c9fa506b..851cfa8b 100644 --- a/apps/web/lib/agents/runtime/state.test.ts +++ b/packages/agent-bridge/src/state.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import type { AgentRuntimeEnvelope, AgentRuntimeSnapshot, -} from "@/lib/agents/types"; -import { applyAgentRuntimeEnvelope } from "./state"; +} from "./agents.js"; +import { applyAgentRuntimeEnvelope } from "./state.js"; function snapshot(): AgentRuntimeSnapshot { return { @@ -41,7 +41,7 @@ function snapshot(): AgentRuntimeSnapshot { function event( data: Extract["data"], ): AgentRuntimeEnvelope { - return { sequence: 1, type: "runtime_event", data }; + return { epoch: "runtime", sequence: 1, type: "runtime_event", data }; } describe("agent runtime event reducer", () => { @@ -482,6 +482,7 @@ describe("agent runtime event reducer", () => { expect( applyAgentRuntimeEnvelope(snapshot(), { + epoch: "runtime", sequence: 8, type: "snapshot", data: authoritative, diff --git a/apps/web/lib/agents/runtime/state.ts b/packages/agent-bridge/src/state.ts similarity index 98% rename from apps/web/lib/agents/runtime/state.ts rename to packages/agent-bridge/src/state.ts index 352a1b74..d467e938 100644 --- a/apps/web/lib/agents/runtime/state.ts +++ b/packages/agent-bridge/src/state.ts @@ -2,9 +2,9 @@ import type { AgentQueuedMessage, AgentRuntimeEnvelope, AgentRuntimeSnapshot, -} from "@/lib/agents/types"; -import { agentPromptImageSchema } from "@/lib/agents/types"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; +} from "./agents"; +import { agentPromptImageSchema } from "./agents"; +import { agentProviderMetadata } from "./catalog"; type AgentRuntimeEvent = Extract< AgentRuntimeEnvelope, diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json new file mode 100644 index 00000000..0420904b --- /dev/null +++ b/packages/agent-runtime/package.json @@ -0,0 +1,27 @@ +{ + "name": "@overtchat/agent-runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@overtchat/agent-bridge": "*", + "ws": "^8.21.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22", + "@types/ws": "^8.18.1", + "typescript": "~6.0.3", + "vitest": "^4.1.6" + } +} diff --git a/apps/web/lib/agents/codex/app-server.test.ts b/packages/agent-runtime/src/codex/app-server.test.ts similarity index 96% rename from apps/web/lib/agents/codex/app-server.test.ts rename to packages/agent-runtime/src/codex/app-server.test.ts index 4a5eb40e..6bfcadc6 100644 --- a/apps/web/lib/agents/codex/app-server.test.ts +++ b/packages/agent-runtime/src/codex/app-server.test.ts @@ -2,13 +2,12 @@ import { createHash } from "node:crypto"; import { PassThrough } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); const mocks = vi.hoisted(() => ({ spawnOnHost: vi.fn(), })); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ spawnOnHost: mocks.spawnOnHost, })); @@ -16,7 +15,7 @@ import { CodexAppServer, startCodexAppServer } from "./app-server"; import type { AgentProcess, AgentProcessExit, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; @@ -298,7 +297,7 @@ describe("CodexAppServer", () => { mocks.spawnOnHost.mockReturnValueOnce(process); const server = await startCodexAppServer( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, "/opt/bin/codex", "/workspace", { enableGoals: true }, @@ -310,7 +309,7 @@ describe("CodexAppServer", () => { expect(mocks.spawnOnHost).toHaveBeenCalledOnce(); expect(mocks.spawnOnHost).toHaveBeenCalledWith( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { command: "/opt/bin/codex", args: ["app-server", "proxy", "--enable", "goals"], @@ -340,7 +339,7 @@ describe("CodexAppServer", () => { .mockReturnValueOnce(standalone); const server = await startCodexAppServer( - { connectorId: "connector", transport: "ssh", alias: "devbox" }, + { transport: "ssh", alias: "devbox" }, "codex", "/workspace", { enableGoals: true }, @@ -352,7 +351,7 @@ describe("CodexAppServer", () => { expect(mocks.spawnOnHost).toHaveBeenNthCalledWith( 1, - { connectorId: "connector", transport: "ssh", alias: "devbox" }, + { transport: "ssh", alias: "devbox" }, { command: "codex", args: ["app-server", "proxy", "--enable", "goals"], @@ -361,7 +360,7 @@ describe("CodexAppServer", () => { ); expect(mocks.spawnOnHost).toHaveBeenNthCalledWith( 2, - { connectorId: "connector", transport: "ssh", alias: "devbox" }, + { transport: "ssh", alias: "devbox" }, { command: "codex", args: ["app-server", "--enable", "goals", "--stdio"], diff --git a/apps/web/lib/agents/codex/app-server.ts b/packages/agent-runtime/src/codex/app-server.ts similarity index 98% rename from apps/web/lib/agents/codex/app-server.ts rename to packages/agent-runtime/src/codex/app-server.ts index d0e38b3d..fdb90a56 100644 --- a/apps/web/lib/agents/codex/app-server.ts +++ b/packages/agent-runtime/src/codex/app-server.ts @@ -1,12 +1,11 @@ -import "server-only"; import { Duplex, PassThrough, Writable } from "node:stream"; import WebSocket from "ws"; -import type { AgentProcess, HostTarget } from "@/lib/agents/runtime/process"; -import { spawnOnHost } from "@/lib/agents/runtime/process"; +import type { AgentProcess, HostTarget } from "@overtchat/agent-runtime/runtime/process"; +import { spawnOnHost } from "@overtchat/agent-runtime/runtime/process"; import { JsonlDecoder, serializeJsonLine, -} from "@/lib/agents/runtime/jsonl"; +} from "@overtchat/agent-runtime/runtime/jsonl"; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const INITIALIZE_TIMEOUT_MS = 60_000; diff --git a/apps/web/lib/agents/codex/client.test.ts b/packages/agent-runtime/src/codex/client.test.ts similarity index 96% rename from apps/web/lib/agents/codex/client.test.ts rename to packages/agent-runtime/src/codex/client.test.ts index 83fbcfa2..e20a7dbb 100644 --- a/apps/web/lib/agents/codex/client.test.ts +++ b/packages/agent-runtime/src/codex/client.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); const mocks = vi.hoisted(() => ({ listCodexCustomPrompts: vi.fn(), @@ -12,7 +11,7 @@ vi.mock("./commands", async (importOriginal) => ({ ...(await importOriginal()), listCodexCustomPrompts: mocks.listCodexCustomPrompts, })); -vi.mock("@/lib/agents/runtime/materialize-images", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/materialize-images", () => ({ materializeAgentImages: mocks.materializeAgentImages, })); @@ -386,7 +385,7 @@ describe("CodexRuntimeClient", () => { it("discovers and invokes native skills and custom prompts", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); @@ -530,7 +529,7 @@ describe("CodexRuntimeClient", () => { }, ]; const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -547,7 +546,7 @@ describe("CodexRuntimeClient", () => { goal: null, }); expect(mocks.startCodexAppServer).toHaveBeenCalledWith( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, "codex", "/workspace", { enableGoals: true }, @@ -609,7 +608,7 @@ describe("CodexRuntimeClient", () => { it("keeps plans, terminal input, and child activity after sparse completion", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -736,7 +735,7 @@ describe("CodexRuntimeClient", () => { it("ignores aggregate diff telemetry and keeps concrete file changes", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -791,7 +790,7 @@ describe("CodexRuntimeClient", () => { it("refreshes native skills when Codex reports a change", async () => { server.goalSupported = true; const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; @@ -820,7 +819,7 @@ describe("CodexRuntimeClient", () => { it("starts a native thread and maps streamed activity", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; @@ -948,7 +947,7 @@ describe("CodexRuntimeClient", () => { it("preserves streamed work when the completed turn only includes the final answer", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -1118,7 +1117,7 @@ describe("CodexRuntimeClient", () => { it("hydrates complete native history after resuming a thread", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1250,7 +1249,7 @@ describe("CodexRuntimeClient", () => { ], }); const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1292,7 +1291,7 @@ describe("CodexRuntimeClient", () => { it("unsubscribes a resumed thread before stopping its app-server", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1318,7 +1317,7 @@ describe("CodexRuntimeClient", () => { "thread thread-1 already has an active writer", ); const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1368,7 +1367,7 @@ describe("CodexRuntimeClient", () => { it("round-trips native approvals through the generic interaction contract", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; @@ -1415,7 +1414,7 @@ describe("CodexRuntimeClient", () => { it("waits for the interrupted turn to become terminal", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -1448,7 +1447,7 @@ describe("CodexRuntimeClient", () => { it("waits for the compaction turn to complete", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; @@ -1505,7 +1504,7 @@ describe("CodexRuntimeClient", () => { it("maps secret questions and clears requests resolved by app-server", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; @@ -1543,16 +1542,16 @@ describe("CodexRuntimeClient", () => { }); }); - it("supports legacy Codex user-input requests", async () => { + it("supports tool-scoped Codex user-input requests", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; client.onEvent((event) => events.push(event)); await client.getState(); - server.ask("legacy-question", "tool/requestUserInput", { + server.ask("tool-question", "tool/requestUserInput", { questions: [ { id: "choice", @@ -1569,14 +1568,14 @@ describe("CodexRuntimeClient", () => { }); expect(events.at(-1)).toMatchObject({ type: "interaction_request", - id: "codex:legacy-question", + id: "codex:tool-question", method: "select", options: ["A", "B"], }); - client.respondToInteraction("codex:legacy-question", { value: "B" }); + client.respondToInteraction("codex:tool-question", { value: "B" }); expect(server.responses.at(-1)).toEqual({ - id: "legacy-question", + id: "tool-question", result: { answers: { choice: { answers: ["B"] }, @@ -1587,7 +1586,7 @@ describe("CodexRuntimeClient", () => { it("handles typed MCP elicitation forms and authorization URLs", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; @@ -1711,7 +1710,7 @@ describe("CodexRuntimeClient", () => { it("uses current-turn usage for context while retaining cumulative totals", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -1757,7 +1756,7 @@ describe("CodexRuntimeClient", () => { it("reads account usage without starting a turn", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -1807,7 +1806,7 @@ describe("CodexRuntimeClient", () => { "codex account authentication required to read token usage", ); const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); await client.getState(); @@ -1824,7 +1823,7 @@ describe("CodexRuntimeClient", () => { it("edits the first user message by forking before its native turn", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1885,7 +1884,7 @@ describe("CodexRuntimeClient", () => { it("deletes a discarded native fork", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1915,7 +1914,7 @@ describe("CodexRuntimeClient", () => { it("forks through an assistant turn without mutating the source", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1973,7 +1972,7 @@ describe("CodexRuntimeClient", () => { it("explains when first-message editing needs newer Codex support", async () => { server.forkError = new Error("unknown field `beforeTurnId`"); const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace", @@ -1992,7 +1991,7 @@ describe("CodexRuntimeClient", () => { it("emits each question in a multi-question request", async () => { const client = new CodexRuntimeClient( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, { executable: "codex", cwd: "/workspace" }, ); const events: Array> = []; diff --git a/apps/web/lib/agents/codex/client.ts b/packages/agent-runtime/src/codex/client.ts similarity index 98% rename from apps/web/lib/agents/codex/client.ts rename to packages/agent-runtime/src/codex/client.ts index 332f6b1e..cfabe294 100644 --- a/apps/web/lib/agents/codex/client.ts +++ b/packages/agent-runtime/src/codex/client.ts @@ -1,4 +1,3 @@ -import "server-only"; import type { AgentCollaborationMode, AgentGoal, @@ -9,23 +8,24 @@ import type { AgentSlashCommand, AgentThinkingLevel, AgentUsageSnapshot, -} from "@/lib/agents/types"; -import { AGENT_GOAL_STATUSES } from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; +import { AGENT_GOAL_STATUSES } from "@overtchat/agent-bridge"; import type { AgentRuntimeClient, AgentRuntimeEvent, + AgentSubmissionOptions, ResolvedAgentImage, AgentSessionForkResult, AgentSessionLaunch, -} from "@/lib/agents/providers/types"; -import type { HostTarget } from "@/lib/agents/runtime/process"; -import { materializeAgentImages } from "@/lib/agents/runtime/materialize-images"; +} from "@overtchat/agent-runtime/providers/types"; +import type { HostTarget } from "@overtchat/agent-runtime/runtime/process"; +import { materializeAgentImages } from "@overtchat/agent-runtime/runtime/materialize-images"; import { type CodexAppServer, type CodexAppServerRequest, type JsonRpcId, startCodexAppServer, -} from "@/lib/agents/codex/app-server"; +} from "@overtchat/agent-runtime/codex/app-server"; import { codexDefaultThinkingLevel, codexSessionMetadata, @@ -41,7 +41,7 @@ import { type CodexThread, type CodexTurn, type UnknownRecord, -} from "@/lib/agents/codex/protocol"; +} from "@overtchat/agent-runtime/codex/protocol"; import { commandMap, expandCodexCustomPrompt, @@ -51,7 +51,7 @@ import { publicCommands, skillInput, type CodexDiscoveredCommand, -} from "@/lib/agents/codex/commands"; +} from "@overtchat/agent-runtime/codex/commands"; const TURN_COMPLETION_TIMEOUT_MS = 30_000; const COMPACTION_TIMEOUT_MS = 5 * 60_000; @@ -932,14 +932,22 @@ export class CodexRuntimeClient implements AgentRuntimeClient { async prompt( message: string, images: readonly ResolvedAgentImage[] = [], + options: AgentSubmissionOptions = {}, ): Promise { await this.readyPromise; this.assertInteractive(); - const input = this.createKnownUserInput(message, images); + const input = this.createKnownUserInput( + message, + images, + options.clientMessageId, + ); this.pendingPromptInput = input; try { const response = await this.server.request("turn/start", { threadId: this.thread!.id, + ...(options.clientMessageId + ? { clientUserMessageId: options.clientMessageId } + : {}), input: await this.resolvePromptInput(message, images), model: this.selectedModel || null, ...(this.selectedThinking @@ -963,6 +971,7 @@ export class CodexRuntimeClient implements AgentRuntimeClient { async steer( message: string, images: readonly ResolvedAgentImage[] = [], + options: AgentSubmissionOptions = {}, ): Promise { await this.readyPromise; this.assertInteractive(); @@ -971,11 +980,14 @@ export class CodexRuntimeClient implements AgentRuntimeClient { const response = await this.server.request("turn/steer", { threadId: this.thread!.id, expectedTurnId: turnId, + ...(options.clientMessageId + ? { clientUserMessageId: options.clientMessageId } + : {}), input: await this.resolvePromptInput(message, images), }); this.rememberUserInput( turnId, - this.createKnownUserInput(message, images), + this.createKnownUserInput(message, images, options.clientMessageId), ); return response; } @@ -2097,9 +2109,12 @@ export class CodexRuntimeClient implements AgentRuntimeClient { private createKnownUserInput( text: string, images: readonly ResolvedAgentImage[] = [], + clientMessageId?: string, ): KnownUserInput { return { - id: `overtchat:codex-user:${++this.nextUserInputId}`, + id: + clientMessageId ?? + `overtchat:codex-user:${++this.nextUserInputId}`, text, images: images.map(({ uploadId, filename, mediaType }) => ({ uploadId, diff --git a/apps/web/lib/agents/codex/commands.test.ts b/packages/agent-runtime/src/codex/commands.test.ts similarity index 98% rename from apps/web/lib/agents/codex/commands.test.ts rename to packages/agent-runtime/src/codex/commands.test.ts index 074d673d..4c57e913 100644 --- a/apps/web/lib/agents/codex/commands.test.ts +++ b/packages/agent-runtime/src/codex/commands.test.ts @@ -1,12 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); const mocks = vi.hoisted(() => ({ executeOnHost: vi.fn(), })); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ executeOnHost: mocks.executeOnHost, })); diff --git a/apps/web/lib/agents/codex/commands.ts b/packages/agent-runtime/src/codex/commands.ts similarity index 98% rename from apps/web/lib/agents/codex/commands.ts rename to packages/agent-runtime/src/codex/commands.ts index df76ed58..adaabafc 100644 --- a/apps/web/lib/agents/codex/commands.ts +++ b/packages/agent-runtime/src/codex/commands.ts @@ -1,15 +1,14 @@ -import "server-only"; import path from "node:path"; -import type { AgentSlashCommand } from "@/lib/agents/types"; +import type { AgentSlashCommand } from "@overtchat/agent-bridge"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; import { recordOf, stringOf, type UnknownRecord, -} from "@/lib/agents/codex/protocol"; +} from "@overtchat/agent-runtime/codex/protocol"; const MAX_CUSTOM_PROMPTS = 100; const PROMPT_PATH_MARKER = "OVERTCHAT_PROMPT_PATH"; diff --git a/apps/web/lib/agents/codex/probe.test.ts b/packages/agent-runtime/src/codex/probe.test.ts similarity index 91% rename from apps/web/lib/agents/codex/probe.test.ts rename to packages/agent-runtime/src/codex/probe.test.ts index 52a3f1d7..e7f3441f 100644 --- a/apps/web/lib/agents/codex/probe.test.ts +++ b/packages/agent-runtime/src/codex/probe.test.ts @@ -1,13 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); const mocks = vi.hoisted(() => ({ executeOnHost: vi.fn(), startCodexAppServer: vi.fn(), })); -vi.mock("@/lib/agents/runtime/discovery", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/discovery", () => ({ parseAgentVersion: (stdout: string) => /codex-cli\s+(\S+)/u.exec(stdout)?.[1] ?? null, shellModesForTarget: () => ["interactive", "login"], @@ -21,7 +20,7 @@ vi.mock("@/lib/agents/runtime/discovery", () => ({ ) => ({ ...target, shellMode }), })); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ executeOnHost: mocks.executeOnHost, })); @@ -71,7 +70,7 @@ describe("Codex connection probing", () => { await expect( probeCodexTarget( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, "/opt/bin/codex", ), ).resolves.toMatchObject({ @@ -105,7 +104,7 @@ describe("Codex connection probing", () => { await expect( probeCodexTarget( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, "codex", ), ).rejects.toThrow("Codex is installed but not signed in"); diff --git a/apps/web/lib/agents/codex/probe.ts b/packages/agent-runtime/src/codex/probe.ts similarity index 89% rename from apps/web/lib/agents/codex/probe.ts rename to packages/agent-runtime/src/codex/probe.ts index 990a66d3..bac5dc7a 100644 --- a/apps/web/lib/agents/codex/probe.ts +++ b/packages/agent-runtime/src/codex/probe.ts @@ -1,21 +1,20 @@ -import "server-only"; import type { ConnectorShellMode } from "@overtchat/agent-bridge"; import type { AgentConnectionDraft, AgentReadyConnectionProbe, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { parseAgentVersion, shellModesForTarget, targetForConnectionDraft, targetWithShellMode, -} from "@/lib/agents/runtime/discovery"; +} from "@overtchat/agent-runtime/runtime/discovery"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; -import { startCodexAppServer } from "@/lib/agents/codex/app-server"; -import { parseCodexModels, recordOf } from "@/lib/agents/codex/protocol"; +} from "@overtchat/agent-runtime/runtime/process"; +import { startCodexAppServer } from "@overtchat/agent-runtime/codex/app-server"; +import { parseCodexModels, recordOf } from "@overtchat/agent-runtime/codex/protocol"; const MODEL_PROBE_TIMEOUT_MS = 120_000; diff --git a/apps/web/lib/agents/codex/protocol.test.ts b/packages/agent-runtime/src/codex/protocol.test.ts similarity index 100% rename from apps/web/lib/agents/codex/protocol.test.ts rename to packages/agent-runtime/src/codex/protocol.test.ts diff --git a/apps/web/lib/agents/codex/protocol.ts b/packages/agent-runtime/src/codex/protocol.ts similarity index 99% rename from apps/web/lib/agents/codex/protocol.ts rename to packages/agent-runtime/src/codex/protocol.ts index 566ff2d3..73d6c705 100644 --- a/apps/web/lib/agents/codex/protocol.ts +++ b/packages/agent-runtime/src/codex/protocol.ts @@ -3,7 +3,7 @@ import type { AgentProviderSessionMetadata, AgentSessionStats, AgentThinkingLevel, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; export type UnknownRecord = Record; diff --git a/apps/web/lib/agents/codex/sessions.test.ts b/packages/agent-runtime/src/codex/sessions.test.ts similarity index 94% rename from apps/web/lib/agents/codex/sessions.test.ts rename to packages/agent-runtime/src/codex/sessions.test.ts index c2055a09..1faa389c 100644 --- a/apps/web/lib/agents/codex/sessions.test.ts +++ b/packages/agent-runtime/src/codex/sessions.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); const mocks = vi.hoisted(() => ({ startCodexAppServer: vi.fn(), @@ -53,7 +52,7 @@ describe("Codex workspace session discovery", () => { await expect( listCodexWorkspaceSessions( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, "/opt/bin/codex", "/workspace", ), @@ -91,7 +90,7 @@ describe("Codex workspace session discovery", () => { await expect( listCodexWorkspaceSessions( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, "codex", "/workspace", ), diff --git a/apps/web/lib/agents/codex/sessions.ts b/packages/agent-runtime/src/codex/sessions.ts similarity index 84% rename from apps/web/lib/agents/codex/sessions.ts rename to packages/agent-runtime/src/codex/sessions.ts index a6ee1d65..2d28d03f 100644 --- a/apps/web/lib/agents/codex/sessions.ts +++ b/packages/agent-runtime/src/codex/sessions.ts @@ -1,13 +1,12 @@ -import "server-only"; -import type { AgentProviderSessionMetadata } from "@/lib/agents/types"; -import type { HostTarget } from "@/lib/agents/runtime/process"; -import { startCodexAppServer } from "@/lib/agents/codex/app-server"; +import type { AgentProviderSessionMetadata } from "@overtchat/agent-bridge"; +import type { HostTarget } from "@overtchat/agent-runtime/runtime/process"; +import { startCodexAppServer } from "@overtchat/agent-runtime/codex/app-server"; import { codexSessionMetadata, parseCodexThread, recordOf, stringOf, -} from "@/lib/agents/codex/protocol"; +} from "@overtchat/agent-runtime/codex/protocol"; const MAX_SESSIONS = 200; const PAGE_SIZE = 100; diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts new file mode 100644 index 00000000..208589c8 --- /dev/null +++ b/packages/agent-runtime/src/index.ts @@ -0,0 +1,10 @@ +export * from "./providers/registry"; +export * from "./providers/types"; +export { + discoverAgentInstallations, + targetForDiscovery, +} from "./runtime/discovery"; +export * from "./runtime/filesystem"; +export * from "./runtime/git"; +export * from "./runtime/process"; +export * from "./runtime/registry"; diff --git a/apps/web/lib/agents/pi/client.test.ts b/packages/agent-runtime/src/pi/client.test.ts similarity index 99% rename from apps/web/lib/agents/pi/client.test.ts rename to packages/agent-runtime/src/pi/client.test.ts index 33ee0d00..87aebb6d 100644 --- a/apps/web/lib/agents/pi/client.test.ts +++ b/packages/agent-runtime/src/pi/client.test.ts @@ -1,13 +1,12 @@ import { PassThrough } from "node:stream"; import { describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); import { PiRpcClient } from "./client"; import type { AgentProcess, AgentProcessExit, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; class FakeAgentProcess implements AgentProcess { readonly stdin = new PassThrough(); diff --git a/apps/web/lib/agents/pi/client.ts b/packages/agent-runtime/src/pi/client.ts similarity index 97% rename from apps/web/lib/agents/pi/client.ts rename to packages/agent-runtime/src/pi/client.ts index face101f..59a802b9 100644 --- a/apps/web/lib/agents/pi/client.ts +++ b/packages/agent-runtime/src/pi/client.ts @@ -1,23 +1,25 @@ -import "server-only"; import type { AgentModel, AgentProviderId, AgentSlashCommand, AgentSessionStats, AgentThinkingLevel, -} from "@/lib/agents/types"; -import type { ResolvedAgentImage } from "@/lib/agents/providers/types"; -import { AGENT_THINKING_LEVELS } from "@/lib/agents/types"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; +} from "@overtchat/agent-bridge"; +import type { + AgentSubmissionOptions, + ResolvedAgentImage, +} from "@overtchat/agent-runtime/providers/types"; +import { AGENT_THINKING_LEVELS } from "@overtchat/agent-bridge"; +import { agentProviderMetadata } from "@overtchat/agent-bridge"; import { type AgentProcess, type HostTarget, spawnOnHost, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; import { JsonlDecoder, serializeJsonLine, -} from "@/lib/agents/runtime/jsonl"; +} from "@overtchat/agent-runtime/runtime/jsonl"; import { parsePiCommands, parsePiModels, @@ -25,7 +27,7 @@ import { parsePiThinkingLevels, type PiRpcCommand, type PiRpcEvent, -} from "@/lib/agents/pi/protocol"; +} from "@overtchat/agent-runtime/pi/protocol"; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const MAX_STDERR_CHARS = 64 * 1024; @@ -299,6 +301,7 @@ export class PiRpcClient { prompt( message: string, images: readonly ResolvedAgentImage[] = [], + _options: AgentSubmissionOptions = {}, ): Promise { return this.request({ type: "prompt", @@ -317,6 +320,7 @@ export class PiRpcClient { steer( message: string, images: readonly ResolvedAgentImage[] = [], + _options: AgentSubmissionOptions = {}, ): Promise { return this.request({ type: "steer", diff --git a/apps/web/lib/agents/pi/fixtures/overtchat-test-prompt.md b/packages/agent-runtime/src/pi/fixtures/overtchat-test-prompt.md similarity index 100% rename from apps/web/lib/agents/pi/fixtures/overtchat-test-prompt.md rename to packages/agent-runtime/src/pi/fixtures/overtchat-test-prompt.md diff --git a/apps/web/lib/agents/pi/fixtures/overtchat-test-skill/SKILL.md b/packages/agent-runtime/src/pi/fixtures/overtchat-test-skill/SKILL.md similarity index 100% rename from apps/web/lib/agents/pi/fixtures/overtchat-test-skill/SKILL.md rename to packages/agent-runtime/src/pi/fixtures/overtchat-test-skill/SKILL.md diff --git a/apps/web/lib/agents/pi/fixtures/rpc-command.js b/packages/agent-runtime/src/pi/fixtures/rpc-command.js similarity index 100% rename from apps/web/lib/agents/pi/fixtures/rpc-command.js rename to packages/agent-runtime/src/pi/fixtures/rpc-command.js diff --git a/apps/web/lib/agents/pi/omp.integration.test.ts b/packages/agent-runtime/src/pi/omp.integration.test.ts similarity index 97% rename from apps/web/lib/agents/pi/omp.integration.test.ts rename to packages/agent-runtime/src/pi/omp.integration.test.ts index bbd67e4c..04d71612 100644 --- a/apps/web/lib/agents/pi/omp.integration.test.ts +++ b/packages/agent-runtime/src/pi/omp.integration.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -vi.mock("server-only", () => ({})); import { startPiRpc } from "./client"; import { probeAgentConnection } from "./probe"; @@ -43,7 +42,7 @@ describe.runIf(runIntegration)("installed Oh My Pi integration", () => { const previousAgentDirectory = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = agentDirectory; const client = startPiRpc( - { connectorId, transport: "local" }, + { transport: "local" }, { provider: "omp", executable, @@ -130,7 +129,7 @@ describe.runIf(runIntegration)("installed Oh My Pi integration", () => { await expect( listAgentWorkspaceSessions( "omp", - { connectorId, transport: "local" }, + { transport: "local" }, workspace, ), ).resolves.toEqual([ diff --git a/apps/web/lib/agents/pi/probe.integration.test.ts b/packages/agent-runtime/src/pi/probe.integration.test.ts similarity index 97% rename from apps/web/lib/agents/pi/probe.integration.test.ts rename to packages/agent-runtime/src/pi/probe.integration.test.ts index 3706e83a..15d0448e 100644 --- a/apps/web/lib/agents/pi/probe.integration.test.ts +++ b/packages/agent-runtime/src/pi/probe.integration.test.ts @@ -4,7 +4,6 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -vi.mock("server-only", () => ({})); import { startPiRpc } from "./client"; import { probePiConnection } from "./probe"; @@ -58,7 +57,7 @@ describe.runIf(runIntegration)("installed Pi integration", () => { fs.mkdirSync(workspace); fs.mkdirSync(sessions); const client = startPiRpc( - { connectorId, transport: "local" }, + { transport: "local" }, { executable: process.env.PI_COMMAND ?? "pi", cwd: workspace, @@ -106,7 +105,7 @@ describe.runIf(runIntegration)("installed Pi integration", () => { const workspace = path.join(root, "workspace"); fs.mkdirSync(workspace); const client = startPiRpc( - { connectorId, transport: "local" }, + { transport: "local" }, { executable: process.env.PI_COMMAND ?? "pi", cwd: workspace, diff --git a/apps/web/lib/agents/pi/probe.test.ts b/packages/agent-runtime/src/pi/probe.test.ts similarity index 94% rename from apps/web/lib/agents/pi/probe.test.ts rename to packages/agent-runtime/src/pi/probe.test.ts index 69089138..5c7bc009 100644 --- a/apps/web/lib/agents/pi/probe.test.ts +++ b/packages/agent-runtime/src/pi/probe.test.ts @@ -5,11 +5,10 @@ const mocks = vi.hoisted(() => ({ startPiRpc: vi.fn(), })); -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ executeOnHost: mocks.executeOnHost, })); -vi.mock("@/lib/agents/pi/client", () => ({ +vi.mock("@overtchat/agent-runtime/pi/client", () => ({ startPiRpc: mocks.startPiRpc, })); @@ -17,7 +16,7 @@ import { probeAgentConnection, probeAgentTarget, } from "./probe"; -import { discoverAgentInstallations } from "@/lib/agents/runtime/discovery"; +import { discoverAgentInstallations } from "@overtchat/agent-runtime/runtime/discovery"; const connectorId = "11111111-1111-4111-8111-111111111111"; const model = { @@ -69,7 +68,6 @@ describe("agent connection probing", () => { }); const target = { - connectorId, transport: "ssh" as const, alias: "macbook", shellMode: "interactive" as const, @@ -95,7 +93,6 @@ describe("agent connection probing", () => { expect(mocks.startPiRpc).toHaveBeenCalledWith( { - connectorId, transport: "local", shellMode: "interactive", }, @@ -140,7 +137,6 @@ describe("agent connection probing", () => { await expect( discoverAgentInstallations({ - connectorId, transport: "ssh", alias: "devbox", }), @@ -159,7 +155,6 @@ describe("agent connection probing", () => { expect(mocks.executeOnHost).toHaveBeenNthCalledWith( 2, { - connectorId, transport: "ssh", alias: "devbox", shellMode: "interactive", @@ -182,7 +177,6 @@ describe("agent connection probing", () => { await expect( discoverAgentInstallations({ - connectorId, transport: "local", }), ).resolves.toEqual([]); @@ -211,7 +205,6 @@ describe("agent connection probing", () => { }); expect(mocks.startPiRpc).toHaveBeenCalledWith( { - connectorId, transport: "ssh", alias: "macbook", shellMode: "login", @@ -225,7 +218,6 @@ describe("agent connection probing", () => { it("tries the stored shell mode first during revalidation", async () => { await probeAgentTarget( { - connectorId, transport: "local", shellMode: "login", }, @@ -236,7 +228,6 @@ describe("agent connection probing", () => { expect(mocks.executeOnHost).toHaveBeenCalledTimes(1); expect(mocks.executeOnHost).toHaveBeenCalledWith( { - connectorId, transport: "local", shellMode: "login", }, diff --git a/apps/web/lib/agents/pi/probe.ts b/packages/agent-runtime/src/pi/probe.ts similarity index 91% rename from apps/web/lib/agents/pi/probe.ts rename to packages/agent-runtime/src/pi/probe.ts index 80799513..fe4ee27a 100644 --- a/apps/web/lib/agents/pi/probe.ts +++ b/packages/agent-runtime/src/pi/probe.ts @@ -1,23 +1,22 @@ -import "server-only"; import type { ConnectorShellMode } from "@overtchat/agent-bridge"; import type { AgentConnectionDraft, AgentConnectionProbe, AgentProviderId, AgentReadyConnectionProbe, -} from "@/lib/agents/types"; -import { agentProviderMetadata } from "@/lib/agents/catalog"; +} from "@overtchat/agent-bridge"; +import { agentProviderMetadata } from "@overtchat/agent-bridge"; import { parseAgentVersion, shellModesForTarget, targetForConnectionDraft, targetWithShellMode, -} from "@/lib/agents/runtime/discovery"; +} from "@overtchat/agent-runtime/runtime/discovery"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; -import { startPiRpc } from "@/lib/agents/pi/client"; +} from "@overtchat/agent-runtime/runtime/process"; +import { startPiRpc } from "@overtchat/agent-runtime/pi/client"; const MODEL_PROBE_TIMEOUT_MS = 120_000; diff --git a/apps/web/lib/agents/pi/protocol.test.ts b/packages/agent-runtime/src/pi/protocol.test.ts similarity index 100% rename from apps/web/lib/agents/pi/protocol.test.ts rename to packages/agent-runtime/src/pi/protocol.test.ts diff --git a/apps/web/lib/agents/pi/protocol.ts b/packages/agent-runtime/src/pi/protocol.ts similarity index 98% rename from apps/web/lib/agents/pi/protocol.ts rename to packages/agent-runtime/src/pi/protocol.ts index 81117d3c..0e634f76 100644 --- a/apps/web/lib/agents/pi/protocol.ts +++ b/packages/agent-runtime/src/pi/protocol.ts @@ -5,8 +5,8 @@ import type { AgentSlashCommand, AgentSessionStats, AgentThinkingLevel, -} from "@/lib/agents/types"; -import { AGENT_THINKING_LEVELS } from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; +import { AGENT_THINKING_LEVELS } from "@overtchat/agent-bridge"; const modelCostSchema = z .object({ diff --git a/apps/web/lib/agents/pi/sessions.ts b/packages/agent-runtime/src/pi/sessions.ts similarity index 96% rename from apps/web/lib/agents/pi/sessions.ts rename to packages/agent-runtime/src/pi/sessions.ts index b353f95c..44789720 100644 --- a/apps/web/lib/agents/pi/sessions.ts +++ b/packages/agent-runtime/src/pi/sessions.ts @@ -1,11 +1,10 @@ -import "server-only"; import { z } from "zod"; -import type { AgentProviderId } from "@/lib/agents/types"; -import type { ProviderSessionMetadata } from "@/lib/db/agentConnections"; +import type { AgentProviderId } from "@overtchat/agent-bridge"; +import type { AgentProviderSessionMetadata as ProviderSessionMetadata } from "@overtchat/agent-bridge"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; const SESSION_SCAN_TIMEOUT_MS = 60_000; diff --git a/apps/web/lib/agents/providers/codex.test.ts b/packages/agent-runtime/src/providers/codex.test.ts similarity index 98% rename from apps/web/lib/agents/providers/codex.test.ts rename to packages/agent-runtime/src/providers/codex.test.ts index 40f4eaf1..c3f3f1a1 100644 --- a/apps/web/lib/agents/providers/codex.test.ts +++ b/packages/agent-runtime/src/providers/codex.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -vi.mock("server-only", () => ({})); import { codexProviderAdapter } from "./codex"; diff --git a/apps/web/lib/agents/providers/codex.ts b/packages/agent-runtime/src/providers/codex.ts similarity index 93% rename from apps/web/lib/agents/providers/codex.ts rename to packages/agent-runtime/src/providers/codex.ts index 15f52524..d89e7b16 100644 --- a/apps/web/lib/agents/providers/codex.ts +++ b/packages/agent-runtime/src/providers/codex.ts @@ -3,21 +3,21 @@ import type { AgentRuntimeEvent, AgentRuntimeEventClassifier, AgentSessionIdentity, -} from "@/lib/agents/providers/types"; +} from "@overtchat/agent-runtime/providers/types"; import type { AgentSessionCommand, AgentSlashCommand, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { mergeAgentSlashCommands, normalizeAgentSessionCommand, -} from "@/lib/agents/runtime/commands"; -import { startCodexRuntime } from "@/lib/agents/codex/client"; +} from "@overtchat/agent-bridge"; +import { startCodexRuntime } from "@overtchat/agent-runtime/codex/client"; import { probeCodexConnection, probeCodexTarget, -} from "@/lib/agents/codex/probe"; -import { listCodexWorkspaceSessions } from "@/lib/agents/codex/sessions"; +} from "@overtchat/agent-runtime/codex/probe"; +import { listCodexWorkspaceSessions } from "@overtchat/agent-runtime/codex/sessions"; const CODEX_COMMANDS: readonly AgentSlashCommand[] = [ { diff --git a/apps/web/lib/agents/providers/pi-rpc.test.ts b/packages/agent-runtime/src/providers/pi-rpc.test.ts similarity index 96% rename from apps/web/lib/agents/providers/pi-rpc.test.ts rename to packages/agent-runtime/src/providers/pi-rpc.test.ts index 2bf771e0..77dda947 100644 --- a/apps/web/lib/agents/providers/pi-rpc.test.ts +++ b/packages/agent-runtime/src/providers/pi-rpc.test.ts @@ -6,14 +6,13 @@ const mocks = vi.hoisted(() => ({ listAgentWorkspaceSessions: vi.fn(), })); -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/pi/client", () => ({ +vi.mock("@overtchat/agent-runtime/pi/client", () => ({ startPiRpc: mocks.startPiRpc, })); -vi.mock("@/lib/agents/pi/probe", () => ({ +vi.mock("@overtchat/agent-runtime/pi/probe", () => ({ probeAgentTarget: mocks.probeAgentTarget, })); -vi.mock("@/lib/agents/pi/sessions", () => ({ +vi.mock("@overtchat/agent-runtime/pi/sessions", () => ({ listAgentWorkspaceSessions: mocks.listAgentWorkspaceSessions, })); @@ -115,7 +114,6 @@ describe("Pi RPC provider adapter", () => { ).resolves.toBe(probe); expect(mocks.probeAgentTarget).toHaveBeenCalledWith( { - connectorId: "connector", transport: "ssh", alias: "workstation", }, diff --git a/apps/web/lib/agents/providers/pi-rpc.ts b/packages/agent-runtime/src/providers/pi-rpc.ts similarity index 88% rename from apps/web/lib/agents/providers/pi-rpc.ts rename to packages/agent-runtime/src/providers/pi-rpc.ts index d1f136b4..5f28dcce 100644 --- a/apps/web/lib/agents/providers/pi-rpc.ts +++ b/packages/agent-runtime/src/providers/pi-rpc.ts @@ -1,10 +1,10 @@ -import { agentProviderMetadata } from "@/lib/agents/catalog"; +import { agentProviderMetadata } from "@overtchat/agent-bridge"; import { mergeAgentSlashCommands, normalizeAgentSessionCommand, -} from "@/lib/agents/runtime/commands"; -import { targetForConnectionDraft } from "@/lib/agents/runtime/discovery"; -import type { HostTarget } from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-bridge"; +import { targetForConnectionDraft } from "@overtchat/agent-runtime/runtime/discovery"; +import type { HostTarget } from "@overtchat/agent-runtime/runtime/process"; import type { AgentProviderAdapter, AgentRuntimeEvent, @@ -12,14 +12,14 @@ import type { AgentRuntimeEventClassifier, AgentSessionIdentity, AgentSessionLaunch, -} from "@/lib/agents/providers/types"; +} from "@overtchat/agent-runtime/providers/types"; import type { AgentSlashCommand, -} from "@/lib/agents/types"; -import { startPiRpc } from "@/lib/agents/pi/client"; -import { parsePiCommands } from "@/lib/agents/pi/protocol"; -import { probeAgentTarget } from "@/lib/agents/pi/probe"; -import { listAgentWorkspaceSessions } from "@/lib/agents/pi/sessions"; +} from "@overtchat/agent-bridge"; +import { startPiRpc } from "@overtchat/agent-runtime/pi/client"; +import { parsePiCommands } from "@overtchat/agent-runtime/pi/protocol"; +import { probeAgentTarget } from "@overtchat/agent-runtime/pi/probe"; +import { listAgentWorkspaceSessions } from "@overtchat/agent-runtime/pi/sessions"; const OVERTCHAT_SESSION_COMMANDS: readonly AgentSlashCommand[] = [ { diff --git a/packages/agent-runtime/src/providers/registry.ts b/packages/agent-runtime/src/providers/registry.ts new file mode 100644 index 00000000..b8837e7d --- /dev/null +++ b/packages/agent-runtime/src/providers/registry.ts @@ -0,0 +1,16 @@ +import type { AgentProviderAdapter } from "@overtchat/agent-runtime/providers/types"; +import { createPiRpcProviderAdapter } from "@overtchat/agent-runtime/providers/pi-rpc"; +import { codexProviderAdapter } from "@overtchat/agent-runtime/providers/codex"; +import type { AgentProviderId } from "@overtchat/agent-bridge"; + +const adapters = { + pi: createPiRpcProviderAdapter("pi"), + omp: createPiRpcProviderAdapter("omp"), + codex: codexProviderAdapter, +} satisfies Record; + +export function agentProviderAdapter( + provider: AgentProviderId, +): AgentProviderAdapter { + return adapters[provider]; +} diff --git a/apps/web/lib/agents/providers/types.ts b/packages/agent-runtime/src/providers/types.ts similarity index 94% rename from apps/web/lib/agents/providers/types.ts rename to packages/agent-runtime/src/providers/types.ts index 1864e845..7c4a4537 100644 --- a/apps/web/lib/agents/providers/types.ts +++ b/packages/agent-runtime/src/providers/types.ts @@ -14,8 +14,8 @@ import type { AgentThinkingLevel, AgentInteractionValue, AgentUsageSnapshot, -} from "@/lib/agents/types"; -import type { HostTarget } from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-bridge"; +import type { HostTarget } from "@overtchat/agent-runtime/runtime/process"; export type AgentRuntimeEvent = Extract< AgentRuntimeEnvelope, @@ -56,6 +56,10 @@ export type ResolvedAgentImage = AgentPromptImage & { data: string; }; +export type AgentSubmissionOptions = { + clientMessageId?: string; +}; + export interface AgentRuntimeClient { onEvent(subscriber: (event: AgentRuntimeEvent) => void): () => void; getState(timeoutMs?: number): Promise>; @@ -67,10 +71,12 @@ export interface AgentRuntimeClient { prompt( message: string, images?: readonly ResolvedAgentImage[], + options?: AgentSubmissionOptions, ): Promise; steer( message: string, images?: readonly ResolvedAgentImage[], + options?: AgentSubmissionOptions, ): Promise; abort(): Promise; setModel(provider: string, modelId: string): Promise; diff --git a/apps/web/lib/agents/runtime/discovery.ts b/packages/agent-runtime/src/runtime/discovery.ts similarity index 90% rename from apps/web/lib/agents/runtime/discovery.ts rename to packages/agent-runtime/src/runtime/discovery.ts index b1afe792..df18adfd 100644 --- a/apps/web/lib/agents/runtime/discovery.ts +++ b/packages/agent-runtime/src/runtime/discovery.ts @@ -1,15 +1,15 @@ -import "server-only"; import type { ConnectorShellMode } from "@overtchat/agent-bridge"; -import { AGENT_PROVIDERS } from "@/lib/agents/catalog"; +import { AGENT_PROVIDERS } from "@overtchat/agent-bridge"; import type { AgentConnectionDraft, + AgentDiscoveryTarget, DetectedAgentInstallation, AgentProviderId, -} from "@/lib/agents/types"; +} from "@overtchat/agent-bridge"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; const AGENT_SHELL_MODES = [ "interactive", @@ -50,15 +50,20 @@ export function targetForConnectionDraft( draft: AgentConnectionDraft, ): HostTarget { if (draft.transport === "local") { - return { connectorId: draft.connectorId, transport: "local" }; + return { transport: "local" }; } return { - connectorId: draft.connectorId, transport: "ssh", alias: draft.sshAlias, }; } +export function targetForDiscovery(target: AgentDiscoveryTarget): HostTarget { + return target.transport === "local" + ? { transport: "local" } + : { transport: "ssh", alias: target.sshAlias }; +} + async function discoverAgentInstallationsInMode( target: HostTarget, shellMode: ConnectorShellMode, diff --git a/apps/web/lib/agents/runtime/filesystem.test.ts b/packages/agent-runtime/src/runtime/filesystem.test.ts similarity index 88% rename from apps/web/lib/agents/runtime/filesystem.test.ts rename to packages/agent-runtime/src/runtime/filesystem.test.ts index 4243f986..3e993ac8 100644 --- a/apps/web/lib/agents/runtime/filesystem.test.ts +++ b/packages/agent-runtime/src/runtime/filesystem.test.ts @@ -4,8 +4,7 @@ const mocks = vi.hoisted(() => ({ executeOnHost: vi.fn(), })); -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ executeOnHost: mocks.executeOnHost, })); @@ -31,7 +30,7 @@ describe("agent directory browsing", () => { stderr: "", }); const listing = await listAgentDirectories( - { connectorId, transport: "local" }, + { transport: "local" }, "/srv/project", ); @@ -44,7 +43,7 @@ describe("agent directory browsing", () => { ], }); expect(mocks.executeOnHost).toHaveBeenCalledWith( - { connectorId, transport: "local" }, + { transport: "local" }, expect.objectContaining({ command: "node", args: expect.arrayContaining(["/srv/project"]), @@ -60,7 +59,7 @@ describe("agent directory browsing", () => { await expect( listAgentDirectories( - { connectorId, transport: "local" }, + { transport: "local" }, "/srv/project", ), ).rejects.toThrow("invalid directory list"); diff --git a/apps/web/lib/agents/runtime/filesystem.ts b/packages/agent-runtime/src/runtime/filesystem.ts similarity index 98% rename from apps/web/lib/agents/runtime/filesystem.ts rename to packages/agent-runtime/src/runtime/filesystem.ts index 7bd30c09..f162f68a 100644 --- a/apps/web/lib/agents/runtime/filesystem.ts +++ b/packages/agent-runtime/src/runtime/filesystem.ts @@ -1,9 +1,8 @@ -import "server-only"; import path from "node:path"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; const DIRECTORY_PROBE = ` const fs = require("node:fs"); diff --git a/apps/web/lib/agents/runtime/git.test.ts b/packages/agent-runtime/src/runtime/git.test.ts similarity index 94% rename from apps/web/lib/agents/runtime/git.test.ts rename to packages/agent-runtime/src/runtime/git.test.ts index f5746a46..93ecf7d1 100644 --- a/apps/web/lib/agents/runtime/git.test.ts +++ b/packages/agent-runtime/src/runtime/git.test.ts @@ -13,8 +13,7 @@ const mocks = vi.hoisted(() => ({ executeOnHost: vi.fn(), })); -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ executeOnHost: mocks.executeOnHost, })); @@ -131,7 +130,7 @@ describe("agent workspace Git status", () => { await expect( inspectAgentWorkspaceGitStatus( - { connectorId, transport: "local" }, + { transport: "local" }, "/srv/project", ), ).resolves.toEqual({ @@ -148,7 +147,7 @@ describe("agent workspace Git status", () => { lineStatsComplete: true, }); expect(mocks.executeOnHost).toHaveBeenCalledWith( - { connectorId, transport: "local" }, + { transport: "local" }, { command: "node", args: ["-e", AGENT_WORKSPACE_GIT_PROBE_SCRIPT], @@ -162,7 +161,7 @@ describe("agent workspace Git status", () => { }); await expect( inspectAgentWorkspaceGitStatus( - { connectorId, transport: "local" }, + { transport: "local" }, "/srv/project", ), ).resolves.toMatchObject({ isGit: false, dirty: false }); @@ -173,7 +172,7 @@ describe("agent workspace Git status", () => { }); await expect( inspectAgentWorkspaceGitStatus( - { connectorId, transport: "local" }, + { transport: "local" }, "/srv/project", ), ).rejects.toThrow("invalid Git metadata"); diff --git a/apps/web/lib/agents/runtime/git.ts b/packages/agent-runtime/src/runtime/git.ts similarity index 98% rename from apps/web/lib/agents/runtime/git.ts rename to packages/agent-runtime/src/runtime/git.ts index bcefd9e0..8bfd3b57 100644 --- a/apps/web/lib/agents/runtime/git.ts +++ b/packages/agent-runtime/src/runtime/git.ts @@ -1,9 +1,8 @@ -import "server-only"; -import type { AgentWorkspaceGitStatus } from "@/lib/agents/types"; +import type { AgentWorkspaceGitStatus } from "@overtchat/agent-bridge"; import { executeOnHost, type HostTarget, -} from "@/lib/agents/runtime/process"; +} from "@overtchat/agent-runtime/runtime/process"; export const AGENT_WORKSPACE_GIT_PROBE_SCRIPT = ` const fs = require("node:fs"); diff --git a/apps/web/lib/agents/runtime/jsonl.test.ts b/packages/agent-runtime/src/runtime/jsonl.test.ts similarity index 100% rename from apps/web/lib/agents/runtime/jsonl.test.ts rename to packages/agent-runtime/src/runtime/jsonl.test.ts diff --git a/apps/web/lib/agents/runtime/jsonl.ts b/packages/agent-runtime/src/runtime/jsonl.ts similarity index 100% rename from apps/web/lib/agents/runtime/jsonl.ts rename to packages/agent-runtime/src/runtime/jsonl.ts diff --git a/apps/web/lib/agents/runtime/materialize-images.test.ts b/packages/agent-runtime/src/runtime/materialize-images.test.ts similarity index 90% rename from apps/web/lib/agents/runtime/materialize-images.test.ts rename to packages/agent-runtime/src/runtime/materialize-images.test.ts index c43def6c..556297e6 100644 --- a/apps/web/lib/agents/runtime/materialize-images.test.ts +++ b/packages/agent-runtime/src/runtime/materialize-images.test.ts @@ -4,8 +4,7 @@ const mocks = vi.hoisted(() => ({ executeOnHost: vi.fn(), })); -vi.mock("server-only", () => ({})); -vi.mock("@/lib/agents/runtime/process", () => ({ +vi.mock("@overtchat/agent-runtime/runtime/process", () => ({ executeOnHost: mocks.executeOnHost, })); @@ -55,7 +54,7 @@ describe("agent image materialization", () => { mocks.executeOnHost.mockClear(); await expect( materializeAgentImages( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, [], ), ).resolves.toEqual([]); @@ -69,7 +68,7 @@ describe("agent image materialization", () => { }); await expect( materializeAgentImages( - { connectorId: "connector", transport: "local" }, + { transport: "local" }, [ { uploadId: "11111111-1111-4111-8111-111111111111", diff --git a/apps/web/lib/agents/runtime/materialize-images.ts b/packages/agent-runtime/src/runtime/materialize-images.ts similarity index 87% rename from apps/web/lib/agents/runtime/materialize-images.ts rename to packages/agent-runtime/src/runtime/materialize-images.ts index 651ba6d4..d54057ff 100644 --- a/apps/web/lib/agents/runtime/materialize-images.ts +++ b/packages/agent-runtime/src/runtime/materialize-images.ts @@ -1,7 +1,6 @@ -import "server-only"; -import type { ResolvedAgentImage } from "@/lib/agents/providers/types"; -import type { HostTarget } from "@/lib/agents/runtime/process"; -import { executeOnHost } from "@/lib/agents/runtime/process"; +import type { ResolvedAgentImage } from "@overtchat/agent-runtime/providers/types"; +import type { HostTarget } from "@overtchat/agent-runtime/runtime/process"; +import { executeOnHost } from "@overtchat/agent-runtime/runtime/process"; const SCRIPT = ` const fs = require("node:fs"); diff --git a/apps/web/lib/agents/runtime/process.ts b/packages/agent-runtime/src/runtime/process.ts similarity index 57% rename from apps/web/lib/agents/runtime/process.ts rename to packages/agent-runtime/src/runtime/process.ts index 6623caad..7d209b95 100644 --- a/apps/web/lib/agents/runtime/process.ts +++ b/packages/agent-runtime/src/runtime/process.ts @@ -1,32 +1,20 @@ -import "server-only"; -import type { Readable, Writable } from "node:stream"; -import type { - ConnectorProcessLaunch, - ConnectorShellMode, - ConnectorTarget, -} from "@overtchat/agent-bridge"; -import { hostConnectorBroker } from "@/lib/agents/connector/broker"; - -const DEFAULT_EXEC_TIMEOUT_MS = 15_000; -const MAX_CAPTURE_BYTES = 2 * 1024 * 1024; +import { Writable, type Readable } from "node:stream"; +import type { ConnectorShellMode } from "@overtchat/agent-bridge"; export type HostTarget = - | { - connectorId: string; - transport: "local"; - shellMode?: ConnectorShellMode; - } - | { - connectorId: string; - transport: "ssh"; - alias: string; - shellMode?: ConnectorShellMode; - }; + | { transport: "local"; shellMode?: ConnectorShellMode } + | { transport: "ssh"; alias: string; shellMode?: ConnectorShellMode }; + +export type AgentProcessLaunch = { + command: string; + args?: string[]; + cwd?: string; + env?: Record; +}; -export type AgentProcessLaunch = Omit< - ConnectorProcessLaunch, - "shellMode" ->; +export type AgentProcessHostLaunch = AgentProcessLaunch & { + shellMode: ConnectorShellMode; +}; export type AgentProcessExit = { code: number | null; @@ -42,29 +30,35 @@ export type AgentProcess = { kill(signal?: NodeJS.Signals): boolean; }; -export type CommandResult = { - stdout: string; - stderr: string; -}; +export type CommandResult = { stdout: string; stderr: string }; + +export type ProcessSpawner = ( + target: HostTarget, + launch: AgentProcessHostLaunch, +) => AgentProcess; + +let configuredSpawner: ProcessSpawner | undefined; + +export function configureProcessSpawner(spawner: ProcessSpawner): void { + configuredSpawner = spawner; +} export function spawnOnHost( target: HostTarget, launch: AgentProcessLaunch, ): AgentProcess { - const connectorTarget: ConnectorTarget = - target.transport === "local" - ? { transport: "local" } - : { transport: "ssh", alias: target.alias }; - return hostConnectorBroker.spawn( - target.connectorId, - connectorTarget, - { - ...launch, - shellMode: target.shellMode ?? "interactive", - }, - ); + if (!configuredSpawner) { + throw new Error("The agent process spawner has not been configured."); + } + return configuredSpawner(target, { + ...launch, + shellMode: target.shellMode ?? "interactive", + }); } +const DEFAULT_EXEC_TIMEOUT_MS = 15_000; +const MAX_CAPTURE_BYTES = 2 * 1024 * 1024; + export async function executeOnHost( target: HostTarget, launch: AgentProcessLaunch, @@ -75,7 +69,6 @@ export async function executeOnHost( let stderr = ""; let capturedBytes = 0; let captureError: Error | null = null; - const append = (current: string, chunk: Buffer | string): string => { const text = chunk.toString(); capturedBytes += Buffer.byteLength(text); @@ -92,11 +85,9 @@ export async function executeOnHost( processHandle.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); }); - if (options.stdin !== undefined) processHandle.stdin.end(options.stdin); else processHandle.stdin.end(); - const timeoutMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS; let timeout: NodeJS.Timeout | undefined; let exit: AgentProcessExit; try { @@ -106,28 +97,22 @@ export async function executeOnHost( timeout = setTimeout(() => { processHandle.kill("SIGKILL"); reject( - new Error(`Agent command timed out after ${timeoutMs} milliseconds.`), + new Error( + `Agent command timed out after ${options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS} milliseconds.`, + ), ); - }, timeoutMs); + }, options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS); }), ]); } finally { if (timeout) clearTimeout(timeout); } - if (captureError) throw captureError; - const stderrText = stderr.trim(); - if (exit.error) { - throw new Error(`Unable to start agent command: ${exit.error.message}`); - } + if (exit.error) throw new Error(`Unable to start agent command: ${exit.error.message}`); if (exit.code !== 0) { throw new Error( - stderrText || - `Agent command exited with code ${exit.code ?? "unknown"}.`, + stderr.trim() || `Agent command exited with code ${exit.code ?? "unknown"}.`, ); } - return { - stdout, - stderr, - }; + return { stdout, stderr }; } diff --git a/packages/agent-runtime/src/runtime/registry.test.ts b/packages/agent-runtime/src/runtime/registry.test.ts new file mode 100644 index 00000000..294b3826 --- /dev/null +++ b/packages/agent-runtime/src/runtime/registry.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + prompt: vi.fn(), + stop: vi.fn(), + saveQueue: vi.fn(), +})); + +const stats = { + sessionFile: null, + sessionId: "provider-session", + userMessages: 0, + assistantMessages: 0, + toolCalls: 0, + toolResults: 0, + totalMessages: 0, + tokens: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + cost: 0, +}; + +vi.mock("@overtchat/agent-runtime/providers/registry", () => ({ + agentProviderAdapter: () => ({ + provider: "codex", + capabilities: { steer: true }, + probeConnection: vi.fn(), + probeTarget: vi.fn(), + listWorkspaceSessions: vi.fn(), + startSession: () => ({ + onEvent: vi.fn(), + getState: vi.fn().mockResolvedValue({ + isStreaming: false, + sessionId: "provider-session", + sessionFile: "/sessions/provider-session.jsonl", + }), + getMessages: vi.fn().mockResolvedValue({ messages: [] }), + getAvailableModels: vi.fn().mockResolvedValue([ + { provider: "openai", id: "gpt-5", name: "GPT-5", input: ["text"] }, + ]), + getSessionStats: vi.fn().mockResolvedValue(stats), + getAvailableThinkingLevels: vi.fn().mockResolvedValue([]), + getCommands: vi.fn().mockResolvedValue([]), + prompt: mocks.prompt, + stop: mocks.stop, + }), + sessionIdentity: () => ({ + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", + sessionName: null, + }), + createEventClassifier: () => ({ + classify: () => ({ started: false, ended: false }), + reset: vi.fn(), + }), + commandsFromEvent: () => null, + mergeCommands: (commands: unknown[]) => commands, + normalizeCommand: (command: unknown) => command, + }), +})); + +import { AgentRuntimeRegistry } from "./registry.js"; + +describe("agent runtime queue recovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prompt.mockResolvedValue({ accepted: true }); + mocks.stop.mockResolvedValue(undefined); + mocks.saveQueue.mockResolvedValue(undefined); + }); + + it("resubmits a journaled queue item with its original message identity", async () => { + const registry = new AgentRuntimeRegistry({ + resolveImages: async () => [], + loadQueuedMessages: () => [ + { + id: "message-1", + message: "Continue the task", + status: "sending", + }, + ], + saveQueuedMessages: mocks.saveQueue, + }); + + await registry.getOrStart({ + connectionId: "connection", + workspaceId: "workspace", + provider: "codex", + target: { transport: "local" }, + executable: "codex", + cwd: "/workspace", + sessionId: "session", + providerSessionId: "provider-session", + providerSessionPath: "/sessions/provider-session.jsonl", + }); + + await vi.waitFor(() => { + expect(mocks.prompt).toHaveBeenCalledWith( + "Continue the task", + undefined, + { clientMessageId: "message-1" }, + ); + }); + await vi.waitFor(() => { + expect(mocks.saveQueue).toHaveBeenLastCalledWith("session", []); + }); + await registry.stopAll(); + }); +}); diff --git a/apps/web/lib/agents/runtime/registry.ts b/packages/agent-runtime/src/runtime/registry.ts similarity index 85% rename from apps/web/lib/agents/runtime/registry.ts rename to packages/agent-runtime/src/runtime/registry.ts index 2ce4ff08..d2408430 100644 --- a/apps/web/lib/agents/runtime/registry.ts +++ b/packages/agent-runtime/src/runtime/registry.ts @@ -1,7 +1,7 @@ -import "server-only"; import type { AgentModel, AgentPromptImage, + AgentProviderSessionMetadata, AgentProviderId, AgentQueuedMessage, AgentRuntimeEnvelope, @@ -11,33 +11,24 @@ import type { AgentSessionCommand, AgentSessionStats, AgentThinkingLevel, -} from "@/lib/agents/types"; -import { resolveAgentImages } from "@/lib/agents/runtime/images"; +} from "@overtchat/agent-bridge"; import { applyAgentRuntimeMessageEvent, applyAgentRuntimeStateEvent, -} from "@/lib/agents/runtime/state"; -import { agentProviderMetadata, isAgentProviderId, -} from "@/lib/agents/catalog"; -import { agentProviderAdapter } from "@/lib/agents/providers/registry"; +} from "@overtchat/agent-bridge"; +import { agentProviderAdapter } from "@overtchat/agent-runtime/providers/registry"; import type { AgentProviderAdapter, AgentRuntimeClient, AgentRuntimeEvent, AgentRuntimeEventClassifier, AgentRuntimeInitialState, + ResolvedAgentImage, AgentSessionForkResult, -} from "@/lib/agents/providers/types"; -import { targetForStoredHost } from "@/lib/agents/runtime/target"; -import { - getOwnedAgentSession, - type OwnedAgentSession, - type OwnedAgentWorkspace, - updateAgentSessionMetadata, - upsertAgentSession, -} from "@/lib/db/agentConnections"; +} from "@overtchat/agent-runtime/providers/types"; +import type { HostTarget } from "@overtchat/agent-runtime/runtime/process"; const MAX_REPLAY_EVENTS = 500; const MODEL_DISCOVERY_TIMEOUT_MS = 120_000; @@ -48,7 +39,6 @@ const PROVIDER_IDLE_TIMEOUT_MS = 30_000; type Subscriber = (envelope: AgentRuntimeEnvelope) => void; type RuntimeOwner = { - userId: string; connectionId: string; workspaceId: string; }; @@ -63,6 +53,42 @@ type PendingSubmission = { published: boolean; }; +export type AgentWorkspaceDescriptor = RuntimeOwner & { + provider: AgentProviderId; + target: HostTarget; + executable: string; + cwd: string; + detectedVersion?: string | null; +}; + +export type AgentSessionDescriptor = AgentWorkspaceDescriptor & { + sessionId: string; + providerSessionId: string; + providerSessionPath: string; +}; + +export type AgentRuntimeMetadataPatch = { + name?: string | null; + firstMessage?: string | null; + messageCount?: number; + providerModifiedAt?: Date; +}; + +export type AgentRuntimeRegistryOptions = { + resolveImages: ( + images: readonly AgentPromptImage[], + ) => Promise; + updateSessionMetadata?: ( + sessionId: string, + patch: AgentRuntimeMetadataPatch, + ) => void | Promise; + loadQueuedMessages?: (sessionId: string) => readonly AgentQueuedMessage[]; + saveQueuedMessages?: ( + sessionId: string, + messages: readonly AgentQueuedMessage[], + ) => void | Promise; +}; + function emptyStats(): AgentSessionStats { return { sessionFile: null, @@ -161,6 +187,7 @@ function messageRole(message: unknown): string | null { export class AgentSessionRuntime { private readonly subscribers = new Set(); private readonly replay: AgentRuntimeEnvelope[] = []; + private readonly epoch = crypto.randomUUID(); private sequence = 0; private status: AgentRuntimeSnapshot["status"] = "idle"; private activeTurnStartedAt: number | null = null; @@ -170,7 +197,7 @@ export class AgentSessionRuntime { private thinkingLevels: AgentThinkingLevel[]; private commands: AgentSlashCommand[]; private stats: AgentSessionStats; - private queuedMessages: AgentQueuedMessage[] = []; + private queuedMessages: AgentQueuedMessage[]; private nextQueuedMessageId = 0; private nextSubmissionId = 0; private promptAwaitingStart = false; @@ -197,12 +224,19 @@ export class AgentSessionRuntime { constructor( readonly dbSessionId: string, - readonly userId: string, readonly connectionId: string, readonly workspaceId: string, private readonly adapter: AgentProviderAdapter, private readonly client: AgentRuntimeClient, initial: AgentRuntimeInitialState, + private readonly resolveImages: AgentRuntimeRegistryOptions["resolveImages"], + private readonly updateSessionMetadata: NonNullable< + AgentRuntimeRegistryOptions["updateSessionMetadata"] + >, + private readonly saveQueuedMessages: NonNullable< + AgentRuntimeRegistryOptions["saveQueuedMessages"] + >, + initialQueuedMessages: readonly AgentQueuedMessage[], private readonly onExit: () => void, ) { this.eventClassifier = adapter.createEventClassifier(); @@ -212,10 +246,18 @@ export class AgentSessionRuntime { this.thinkingLevels = initial.thinkingLevels; this.commands = initial.commands; this.stats = initial.stats; + this.queuedMessages = initialQueuedMessages.map((message) => ({ + ...message, + status: "pending", + ...(message.images ? { images: [...message.images] } : {}), + })); this.status = initial.state.isStreaming === true ? "running" : "idle"; this.activeTurnStartedAt = this.status === "running" ? Date.now() : null; this.scheduleIdleStop(); + if (this.queuedMessages.length > 0 && this.status === "idle") { + queueMicrotask(() => void this.drainQueuedMessage()); + } client.onEvent((event) => { let settleRejectedPrompt = false; @@ -362,7 +404,7 @@ export class AgentSessionRuntime { typeof event.title === "string" ) { this.state = { ...this.state, sessionName: event.title }; - void updateAgentSessionMetadata(this.dbSessionId, { + void this.updateSessionMetadata(this.dbSessionId, { name: event.title.trim() || null, }); this.publishSnapshot(); @@ -409,13 +451,16 @@ export class AgentSessionRuntime { subscribe( subscriber: Subscriber, - afterSequence = 0, + after?: { epoch: string; sequence: number }, ): () => void { this.clearIdleStop(); this.subscribers.add(subscriber); - const replay = this.replay.filter( - (envelope) => envelope.sequence > afterSequence, - ); + const replay = + after?.epoch === this.epoch + ? this.replay.filter( + (envelope) => envelope.sequence > after.sequence, + ) + : []; if (replay.length > 0) { for (const envelope of replay) subscriber(envelope); } else { @@ -431,7 +476,10 @@ export class AgentSessionRuntime { return this.adapter.normalizeCommand(command, this.state); } - command(input: AgentSessionCommand): Promise { + command( + input: AgentSessionCommand, + clientMessageId?: string, + ): Promise { const command = this.normalizeCommand(input); const readOnly = readOnlyState(this.state); if ( @@ -452,13 +500,25 @@ export class AgentSessionRuntime { new Error("Session forks must be created by the runtime registry."), ); case "prompt": - return this.submitPrompt(command.message, command.images ?? []); + return this.submitPrompt( + command.message, + command.images ?? [], + clientMessageId, + ); case "abort": return this.abortActiveRun(); case "steer": - return this.submitSteer(command.message, command.images ?? []); + return this.submitSteer( + command.message, + command.images ?? [], + clientMessageId, + ); case "queue": - return this.enqueueMessage(command.message, command.images ?? []); + return this.enqueueMessage( + command.message, + command.images ?? [], + clientMessageId, + ); case "remove_queued_message": return this.removeQueuedMessage(command.id); case "steer_queued_message": @@ -538,6 +598,7 @@ export class AgentSessionRuntime { : ["Make the required code changes and verify them."]), ].join("\n\n"), [], + clientMessageId, ), ); case "compact": @@ -652,7 +713,7 @@ export class AgentSessionRuntime { ? (this.activeTurnStartedAt ?? Date.now()) : null; this.error = undefined; - await updateAgentSessionMetadata(this.dbSessionId, { + await this.updateSessionMetadata(this.dbSessionId, { name: typeof state.sessionName === "string" && state.sessionName.trim() ? state.sessionName.trim() @@ -727,6 +788,7 @@ export class AgentSessionRuntime { private submitPrompt( message: string, images: AgentPromptImage[], + clientMessageId?: string, ): Promise { if (!message && images.length === 0) { return Promise.reject(new Error("Enter a message or attach an image.")); @@ -753,7 +815,7 @@ export class AgentSessionRuntime { } const imageInputError = this.imageInputError(images); if (imageInputError) return Promise.reject(imageInputError); - return this.startPrompt(message, images).finally(() => { + return this.startPrompt(message, images, clientMessageId).finally(() => { if (this.status === "idle") void this.drainQueuedMessage(); }); } @@ -765,7 +827,7 @@ export class AgentSessionRuntime { ): Promise { const images = imageRefs.length > 0 - ? await resolveAgentImages(imageRefs, this.userId) + ? await this.resolveImages(imageRefs) : []; this.turnGeneration += 1; this.eventClassifier.reset(); @@ -785,8 +847,12 @@ export class AgentSessionRuntime { try { const result = images.length > 0 - ? await this.client.prompt(message, images) - : await this.client.prompt(message); + ? await this.client.prompt(message, images, { + clientMessageId: submissionId, + }) + : await this.client.prompt(message, undefined, { + clientMessageId: submissionId, + }); if (this.pendingSubmissions.get(submission.id) === submission) { submission.published = true; this.publishSubmission(submission.id, submission.message, images); @@ -850,7 +916,7 @@ export class AgentSessionRuntime { if (imageInputError) return Promise.reject(imageInputError); const submit = ( - images: Awaited>, + images: ResolvedAgentImage[], ) => { const submission: PendingSubmission = { id: submissionId, @@ -860,8 +926,12 @@ export class AgentSessionRuntime { this.pendingSubmissions.set(submission.id, submission); const request = images.length > 0 - ? this.client.steer(message, images) - : this.client.steer(message); + ? this.client.steer(message, images, { + clientMessageId: submissionId, + }) + : this.client.steer(message, undefined, { + clientMessageId: submissionId, + }); return request.then((result) => { if (this.pendingSubmissions.get(submission.id) === submission) { submission.published = true; @@ -872,7 +942,7 @@ export class AgentSessionRuntime { }; const operation = ( imageRefs.length > 0 - ? resolveAgentImages(imageRefs, this.userId).then(submit) + ? this.resolveImages(imageRefs).then(submit) : submit([]) ) .catch((error) => { @@ -1083,13 +1153,19 @@ export class AgentSessionRuntime { private enqueueMessage( message: string, images: AgentPromptImage[], + clientMessageId?: string, ): Promise { if (!message && images.length === 0) { return Promise.reject(new Error("Enter a message or attach an image.")); } const imageInputError = this.imageInputError(images); if (imageInputError) return Promise.reject(imageInputError); - const queuedMessage = this.addQueuedMessage(message, images, "pending"); + const queuedMessage = this.addQueuedMessage( + message, + images, + "pending", + clientMessageId, + ); if (this.status === "idle") void this.drainQueuedMessage(); return Promise.resolve({ queued: true, id: queuedMessage.id }); } @@ -1098,9 +1174,10 @@ export class AgentSessionRuntime { message: string, images: AgentPromptImage[], status: AgentQueuedMessage["status"], + id = `${this.dbSessionId}:${++this.nextQueuedMessageId}`, ): AgentQueuedMessage { const queuedMessage: AgentQueuedMessage = { - id: `${this.dbSessionId}:${++this.nextQueuedMessageId}`, + id, message, ...(images.length > 0 ? { images } : {}), status, @@ -1201,7 +1278,7 @@ export class AgentSessionRuntime { private publishSubmission( id: string, message: string, - images: Awaited>, + images: ResolvedAgentImage[], ): void { const content = images.length === 0 @@ -1238,6 +1315,7 @@ export class AgentSessionRuntime { } private publishQueueUpdate(): void { + void this.saveQueuedMessages(this.dbSessionId, this.queuedMessages); this.publish({ type: "runtime_event", data: { @@ -1253,10 +1331,13 @@ export class AgentSessionRuntime { private publish( envelope: - | Omit, "sequence"> + | Omit< + Extract, + "epoch" | "sequence" + > | Omit< Extract, - "sequence" + "epoch" | "sequence" >, ): void { const sequenced = @@ -1282,8 +1363,8 @@ export class AgentSessionRuntime { ): AgentRuntimeEnvelope { const sequence = ++this.sequence; return type === "snapshot" - ? { sequence, type, data: data as AgentRuntimeSnapshot } - : { sequence, type, data: data as AgentRuntimeEvent }; + ? { epoch: this.epoch, sequence, type, data: data as AgentRuntimeSnapshot } + : { epoch: this.epoch, sequence, type, data: data as AgentRuntimeEvent }; } } @@ -1291,39 +1372,47 @@ export class AgentRuntimeRegistry { private readonly runtimes = new Map(); private readonly starts = new Map(); - async getOrStart(owned: OwnedAgentSession): Promise { - const existing = this.runtimes.get(owned.agentSession.id); + constructor(private readonly options: AgentRuntimeRegistryOptions) {} + + get(sessionId: string): AgentSessionRuntime | null { + return this.runtimes.get(sessionId) ?? null; + } + + async getOrStart( + descriptor: AgentSessionDescriptor, + ): Promise { + const existing = this.runtimes.get(descriptor.sessionId); if (existing) return existing; - const starting = this.starts.get(owned.agentSession.id); + const starting = this.starts.get(descriptor.sessionId); if (starting) return starting.promise; - const promise = this.startExisting(owned).finally(() => { - this.starts.delete(owned.agentSession.id); + const promise = this.startExisting(descriptor).finally(() => { + this.starts.delete(descriptor.sessionId); }); - this.starts.set(owned.agentSession.id, { - userId: owned.host.userId, - connectionId: owned.connection.id, - workspaceId: owned.workspace.id, + this.starts.set(descriptor.sessionId, { + connectionId: descriptor.connectionId, + workspaceId: descriptor.workspaceId, promise, }); return promise; } async create( - owned: OwnedAgentWorkspace, - ): Promise<{ runtime: AgentSessionRuntime; sessionId: string }> { - const adapter = this.adapterFor(owned.connection.provider); - const client = adapter.startSession( - targetForStoredHost(owned.host, owned.connection.shellMode), - { - executable: owned.connection.executable, - cwd: owned.workspace.path, - detectedVersion: owned.connection.detectedVersion, - }, - ); + sessionId: string, + descriptor: AgentWorkspaceDescriptor, + ): Promise<{ + runtime: AgentSessionRuntime; + session: AgentProviderSessionMetadata; + }> { + const adapter = this.adapterFor(descriptor.provider); + const client = adapter.startSession(descriptor.target, { + executable: descriptor.executable, + cwd: descriptor.cwd, + detectedVersion: descriptor.detectedVersion, + }); try { const initial = await this.loadInitial(adapter, client); const identity = adapter.sessionIdentity(initial.state); - const row = await upsertAgentSession(owned.workspace.id, { + const session: AgentProviderSessionMetadata = { providerSessionId: identity.providerSessionId, providerSessionPath: identity.providerSessionPath, name: identity.sessionName, @@ -1331,15 +1420,15 @@ export class AgentRuntimeRegistry { messageCount: 0, createdAt: new Date(), modifiedAt: new Date(), - }); + }; const runtime = this.register( - row.id, - owned, + sessionId, + descriptor, adapter, client, initial, ); - return { runtime, sessionId: row.id }; + return { runtime, session }; } catch (error) { await client.stop(); throw error; @@ -1347,94 +1436,68 @@ export class AgentRuntimeRegistry { } async fork( - owned: OwnedAgentSession, runtime: AgentSessionRuntime, input: Extract< AgentSessionCommand, { type: "edit_message" | "fork_message" } >, - ): Promise<{ sessionId: string; draft?: string }> { + ): Promise { const fork = await runtime.forkSession( input.messageId, input.type === "edit_message" ? "edit" : "fork", ); - let row; - try { - row = await upsertAgentSession(owned.workspace.id, fork.session); - } catch (error) { - await runtime.discardForkedSession(fork.session).catch(() => {}); - throw error; - } - return { - sessionId: row.id, - ...(fork.draft !== undefined ? { draft: fork.draft } : {}), - }; + return fork; } - async getForUser( - sessionId: string, - userId: string, - ): Promise { - const owned = await getOwnedAgentSession(sessionId, userId); - return owned ? this.getOrStart(owned) : null; + runtimeStatusForSession(sessionId: string): AgentRuntimeStatus { + return this.runtimes.get(sessionId)?.snapshot().status ?? "idle"; } - runtimeStatusForSession( - sessionId: string, - userId: string, - ): AgentRuntimeStatus { + async stopSession(sessionId: string): Promise { const runtime = this.runtimes.get(sessionId); - return runtime?.userId === userId ? runtime.snapshot().status : "idle"; + if (!runtime) return; + await runtime.stop(); + this.runtimes.delete(sessionId); } - async stopWorkspace(workspaceId: string, userId: string): Promise { - await this.stopMatching( - (runtime) => - runtime.workspaceId === workspaceId && runtime.userId === userId, - ); + async stopWorkspace(workspaceId: string): Promise { + await this.stopMatching((runtime) => runtime.workspaceId === workspaceId); } - async stopConnection(connectionId: string, userId: string): Promise { - await this.stopMatching( - (runtime) => - runtime.connectionId === connectionId && runtime.userId === userId, - ); + async stopConnection(connectionId: string): Promise { + await this.stopMatching((runtime) => runtime.connectionId === connectionId); } - async stopUser(userId: string): Promise { - await this.stopMatching((runtime) => runtime.userId === userId); + async stopAll(): Promise { + await this.stopMatching(() => true); } private async startExisting( - owned: OwnedAgentSession, + descriptor: AgentSessionDescriptor, ): Promise { - const adapter = this.adapterFor(owned.connection.provider); - const client = adapter.startSession( - targetForStoredHost(owned.host, owned.connection.shellMode), - { - executable: owned.connection.executable, - cwd: owned.workspace.path, - detectedVersion: owned.connection.detectedVersion, - resume: { - providerSessionId: owned.agentSession.providerSessionId, - providerSessionPath: owned.agentSession.providerSessionPath, - }, + const adapter = this.adapterFor(descriptor.provider); + const client = adapter.startSession(descriptor.target, { + executable: descriptor.executable, + cwd: descriptor.cwd, + detectedVersion: descriptor.detectedVersion, + resume: { + providerSessionId: descriptor.providerSessionId, + providerSessionPath: descriptor.providerSessionPath, }, - ); + }); try { const initial = await this.loadInitial(adapter, client); const identity = adapter.sessionIdentity(initial.state); if ( - identity.providerSessionId !== - owned.agentSession.providerSessionId + identity.providerSessionId !== descriptor.providerSessionId ) { throw new Error( `${agentProviderMetadata(adapter.provider).label} opened a different session than requested.`, ); } return this.register( - owned.agentSession.id, - owned, + descriptor.sessionId, + descriptor, adapter, client, initial, @@ -1447,19 +1510,22 @@ export class AgentRuntimeRegistry { private register( sessionId: string, - owned: OwnedAgentWorkspace, + descriptor: AgentWorkspaceDescriptor, adapter: AgentProviderAdapter, client: AgentRuntimeClient, initial: AgentRuntimeInitialState, ): AgentSessionRuntime { const runtime = new AgentSessionRuntime( sessionId, - owned.host.userId, - owned.connection.id, - owned.workspace.id, + descriptor.connectionId, + descriptor.workspaceId, adapter, client, initial, + this.options.resolveImages, + this.options.updateSessionMetadata ?? (() => {}), + this.options.saveQueuedMessages ?? (() => {}), + this.options.loadQueuedMessages?.(sessionId) ?? [], () => { if (this.runtimes.get(sessionId) === runtime) { this.runtimes.delete(sessionId); @@ -1521,13 +1587,3 @@ export class AgentRuntimeRegistry { } } } - -const globalForAgentRuntimes = globalThis as typeof globalThis & { - overtchatAgentRuntimeRegistry?: AgentRuntimeRegistry; -}; - -export const agentRuntimeRegistry = - globalForAgentRuntimes.overtchatAgentRuntimeRegistry ?? - new AgentRuntimeRegistry(); - -globalForAgentRuntimes.overtchatAgentRuntimeRegistry = agentRuntimeRegistry; diff --git a/packages/agent-runtime/tsconfig.json b/packages/agent-runtime/tsconfig.json new file mode 100644 index 00000000..7f0e6e8a --- /dev/null +++ b/packages/agent-runtime/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/scripts/install-connector.sh b/scripts/install-connector.sh index 7155b58d..eb4bcbfb 100755 --- a/scripts/install-connector.sh +++ b/scripts/install-connector.sh @@ -2,7 +2,7 @@ set -eu repository="yoloyash/overtchat" -connector_version="0.1.0" +connector_version="0.2.0" server="" pair_code="" connector_name=""