Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ app-server generate-ts`) and is in the lint/format ignore lists. Don't hand-edit
`packages/server/src/daemon/paths.ts` names files inside a directory it is
handed and deliberately has no default of its own.
- `HarnessAgentIdSchema` in `packages/contract/src/domain.ts` is the whitelist:
`claude-code`, `codex`, `pi`, `grok` and nothing else. A fifth harness needs a literal
`claude-code`, `codex`, `pi`, `grok`, `cursor` and nothing else. A new harness needs a literal
there, a `packages/server/src/harness/<agent>/` transform, and its adapter added
to the `RegistryLayer` in `packages/server/src/rpc/runtime.ts` — all three, or it
is unreachable at runtime. `harness/registry.ts` is only the lookup table's
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# vibest

In-browser tooling for AI coding agents: a web chat UI over local agents
(Claude Code, Codex, pi, Grok), served by a local Node daemon and also shipped as an
(Claude Code, Codex, pi, Grok, Cursor), served by a local Node daemon and also shipped as an
Electron app. pnpm + Turborepo, TypeScript everywhere.

## Commands
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The composite identity `{ projectId, harnessAgentId, sessionId }` that every ses
_Avoid_: bare sessionId as a wire identity or client-state key

**Harness session id**:
The agent-native session identity (Claude session UUID, Codex thread ID, Grok ACP session id) held in the session's metadata. Internal plumbing for resume/history — never exposed as wire identity.
The agent-native session identity (Claude session UUID, Codex thread ID, Grok ACP session id, Cursor SDK `agentId`) held in the session's metadata. Internal plumbing for resume/history — never exposed as wire identity.
_Avoid_: native id

**Attach**:
Expand Down
13 changes: 13 additions & 0 deletions apps/app/src/features/chat/components/harness-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ export function HarnessIcon({
<path d="M17.5 12H23v11h-5.5V12z" />
</svg>
);
case "cursor":
return (
<svg
aria-hidden="true"
className={className}
fill="#000000"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 3.2v17.6l6.4-5.1 3.5 8.1 2.7-1.2-3.5-8.1L20 10.8 4 3.2z" />
</svg>
);
case "grok":
// 2025 Grok "G" (Jon Vio / xAI). Path from Lobe Icons' grok.svg, matching
// the mark on grok.com — not a made-up glyph.
Expand Down
39 changes: 39 additions & 0 deletions docs/adr/0005-cursor-acp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Cursor reuses the logged-in `cursor-agent` via ACP

Cursor is a first-class harness. The Node adapter spawns `cursor-agent acp`
and reuses the subscription the user already created with `agent login`.

## Context

Cursor exposes three faces: the IDE agent, the `cursor-agent` CLI (ACP over
stdio, plus a one-shot `-p` mode), and `@cursor/sdk`. A `HarnessAgentRuntime`
has to prompt, interrupt, stream, and close without exiting.

`@cursor/sdk` looks like Claude's in-process SDK, but its stored login is
only a key minted by `Cursor.auth.login()` / `CURSOR_API_KEY`. It does not
read the desktop or CLI install. Headless `cursor-agent -p` exits after one
prompt and cannot host a runtime.

`cursor-agent acp` is the same door the user already authenticated. The
binary name must be `cursor-agent`, never `agent`: Grok's CLI ships an
`agent` that wins PATH resolution.

## Decision

- Wire id `cursor`.
- Spawn `cursor-agent acp`. Availability is a PATH lookup
(`VIBEST_CURSOR_EXECUTABLE`, then `cursor-agent`, then `~/.local/bin` /
`~/.cursor/bin`).
- Handshake: `initialize`, then `authenticate` with `methodId: "cursor_login"`
when advertised.
- Probe models with `cursor/list_available_models`, never `session/new`.
- End a turn when `session/prompt` returns. Cursor ACP does not send a
Grok-style `_x.ai` `turn_completed` notice.
- Map vibest `plan` / `ask` / `full` onto ACP session modes `plan` / `ask` /
`agent`. Default is `full` (`agent`).

## Consequences

- Users who already ran `agent login` are available with no extra key.
- Interactive per-tool approval is `session/request_permission`, same as Grok.
- A relocated CLI needs `VIBEST_CURSOR_EXECUTABLE`.
1 change: 1 addition & 0 deletions packages/contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"./session-events": "./src/session-events.ts",
"./claude-code": "./src/claude-code/index.ts",
"./grok": "./src/grok/index.ts",
"./cursor": "./src/cursor/index.ts",
"./codex": "./src/codex/index.ts",
"./codex/protocol": "./src/codex/protocol/index.ts",
"./codex/protocol/v2": "./src/codex/protocol/v2/index.ts"
Expand Down
10 changes: 10 additions & 0 deletions packages/contract/src/cursor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Browser-safe Cursor tool schemas and UI-message types. The pure UI
// vocabulary the SPA renders against; server-side transforms import from here
// too. Runtime (ACP stdio, session lifecycle) lives in @vibest/server.
export * from "./tools";
export type {
CursorUIMessage,
CursorUIMessageChunk,
CursorMetadata,
CursorDataTypes,
} from "./ui-message";
125 changes: 125 additions & 0 deletions packages/contract/src/cursor/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { tool, type InferUITools, type ToolSet, type UIToolInvocation } from "ai";
import { z } from "zod";

// Cursor ACP built-in tools. Keys are the wire `title` values on
// `session/update` `tool_call` frames (`Read`, `Shell`, `Write`, …).
// Input types are observed `rawInput` shapes; `z.custom` is a typed
// pass-through — the transform forwards input and output verbatim and never
// validates.
//
// MCP and unrecognized titles stay off this registry so the part is typed as
// dynamic and the UI renders them with the generic card.

export type ReadInput = {
readonly path?: string;
readonly offset?: number;
readonly limit?: number;
};
export type WriteInput = {
readonly path?: string;
readonly contents?: string;
};
export type StrReplaceInput = {
readonly path?: string;
readonly old_string?: string;
readonly new_string?: string;
};
export type DeleteInput = {
readonly path?: string;
};
export type ShellInput = {
readonly command?: string;
readonly working_directory?: string;
readonly block_until_ms?: number;
};
export type GrepInput = {
readonly pattern?: string;
readonly path?: string;
readonly glob?: string;
};
export type GlobInput = {
readonly glob_pattern?: string;
readonly target_directory?: string;
};
export type WebSearchInput = {
readonly search_term?: string;
readonly explanation?: string;
};
export type WebFetchInput = {
readonly url?: string;
};
export type TaskInput = {
readonly description?: string;
readonly prompt?: string;
};

export const Read = tool({
inputSchema: z.custom<ReadInput>(),
outputSchema: z.unknown(),
});
export const Write = tool({
inputSchema: z.custom<WriteInput>(),
outputSchema: z.unknown(),
});
export const StrReplace = tool({
inputSchema: z.custom<StrReplaceInput>(),
outputSchema: z.unknown(),
});
export const Delete = tool({
inputSchema: z.custom<DeleteInput>(),
outputSchema: z.unknown(),
});
export const Shell = tool({
inputSchema: z.custom<ShellInput>(),
outputSchema: z.unknown(),
});
export const Grep = tool({
inputSchema: z.custom<GrepInput>(),
outputSchema: z.unknown(),
});
export const Glob = tool({
inputSchema: z.custom<GlobInput>(),
outputSchema: z.unknown(),
});
export const WebSearch = tool({
inputSchema: z.custom<WebSearchInput>(),
outputSchema: z.unknown(),
});
export const WebFetch = tool({
inputSchema: z.custom<WebFetchInput>(),
outputSchema: z.unknown(),
});
export const Task = tool({
inputSchema: z.custom<TaskInput>(),
outputSchema: z.unknown(),
});

export const cursorTools = {
Read,
Write,
StrReplace,
Delete,
Shell,
Grep,
Glob,
WebSearch,
WebFetch,
Task,
} satisfies ToolSet;

export type CursorTools = InferUITools<typeof cursorTools>;

export type ReadUIToolInvocation = UIToolInvocation<typeof Read>;
export type WriteUIToolInvocation = UIToolInvocation<typeof Write>;
export type StrReplaceUIToolInvocation = UIToolInvocation<typeof StrReplace>;
export type DeleteUIToolInvocation = UIToolInvocation<typeof Delete>;
export type ShellUIToolInvocation = UIToolInvocation<typeof Shell>;
export type GrepUIToolInvocation = UIToolInvocation<typeof Grep>;
export type GlobUIToolInvocation = UIToolInvocation<typeof Glob>;
export type WebSearchUIToolInvocation = UIToolInvocation<typeof WebSearch>;
export type WebFetchUIToolInvocation = UIToolInvocation<typeof WebFetch>;
export type TaskUIToolInvocation = UIToolInvocation<typeof Task>;

export function isCursorTool(toolName: string): toolName is keyof typeof cursorTools {
return toolName in cursorTools;
}
15 changes: 15 additions & 0 deletions packages/contract/src/cursor/ui-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { InferUIMessageChunk, UIMessage } from "ai";

import type { CursorTools } from "./tools";

export type CursorMetadata = {
/** Cursor ACP session id from `session/new`. */
sessionId: string;
};

// No `data-*` parts — ACP session updates that are not text/reasoning/tools
// stay off the chunk track until a data part earns its keep.
export type CursorDataTypes = Record<never, never>;

export type CursorUIMessage = UIMessage<CursorMetadata, CursorDataTypes, CursorTools>;
export type CursorUIMessageChunk = InferUIMessageChunk<CursorUIMessage>;
8 changes: 7 additions & 1 deletion packages/contract/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { Schema } from "effect";
export const toStandardSchema = <S extends Schema.ConstraintDecoder<unknown>>(schema: S) =>
Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema));

export const HarnessAgentIdSchema = Schema.Literals(["claude-code", "codex", "pi", "grok"]);
export const HarnessAgentIdSchema = Schema.Literals([
"claude-code",
"codex",
"pi",
"grok",
"cursor",
]);
export type HarnessAgentId = typeof HarnessAgentIdSchema.Type;
// Derived from the schema rather than written out a second time: clients that
// need the ids as data (narrowing a URL param, say) would otherwise keep their
Expand Down
32 changes: 32 additions & 0 deletions packages/server/src/harness/cursor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Cursor harness (`cursor-agent acp`)

Verified against `cursor-agent` 2026.08.25. The adapter speaks
[ACP](https://agentclientprotocol.com) over:

```
cursor-agent acp
```

That is the same logged-in CLI the user already ran `agent login` against.
Do not spawn `agent` — Grok's CLI also ships an `agent` binary, and it wins
PATH resolution. Do not use `@cursor/sdk`: its stored login is a separately
minted API key, not the desktop / CLI subscription.

Do not substitute `cursor-agent -p` (headless). That process exits when the
prompt ends and cannot host a `HarnessAgentRuntime`.

Handshake is `initialize` then `authenticate` with `methodId: "cursor_login"`
when that method is advertised. Models come from `cursor/list_available_models`.
A turn ends when `session/prompt` returns — Cursor ACP has no `_x.ai`
`turn_completed` notice.

Availability is a PATH lookup (`cursor-agent`, then `~/.local/bin` /
`~/.cursor/bin`), overridable with `VIBEST_CURSOR_EXECUTABLE`.

Tests speak a fake ACP child over stdio. There is no live-CLI smoke: a real
turn spends tokens and is not a regression gate.

Regenerate nothing — protocol types in `protocol.ts` are hand-written around
the ACP methods and Cursor extensions we consume. After bumping the CLI,
re-probe `initialize`, `authenticate`, `cursor/list_available_models`,
`session/new`, and `session/request_permission` before widening the types.
Loading
Loading