Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .papercuts/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,12 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed.
- For a default-on environment gate, do not use trimmed-value truthiness to detect absence: an unset variable may enable the default, but explicitly empty or whitespace-only overrides must remain fail-closed.
- E2E migration fixtures that edit persisted chat files while Electron is still running can be overwritten by shutdown drains. Seed disk state only after the app closes and before the replacement process launches.

## 2026-09-17 — Cursor via fitchmultz/pi-cursor-sdk

- Consume `pi-cursor-sdk` 0.3.7 (`discoverModels` / `streamCursorLazy`) as an Aiden-owned built-in. Do not load the package's default Pi extension (`dist/index.js`): that registers MCP bridging, slash commands, and `parseArgs(process.argv)` session scope.
- Bind workspace cwd with the SDK session-scope `__testUtils.set` plus Aiden ALS, then globally serialize Cursor streams. The SDK cwd is process-global; per-session turn locks do not cover concurrent chats.
- This agent environment's `npm install` against registry.npmjs.org failed with TLS `SSL_ERROR_SYSCALL` / `ECONNRESET`. GitHub HTTPS clone of the SDK succeeded. Do not add optional peer `@earendil-works/pi-coding-agent` just to satisfy the SDK: it ships a shrinkwrap and CI `npm ci` then demands that whole nested graph. Keep `package-lock.json` in sync for `pi-cursor-sdk` (exact `@hono/node-server@2.0.12`) and `@cursor/sdk` (`@connectrpc/connect-node` → `undici@5.29.0` → `@fastify/busboy@2.1.1`); unpack `@cursor/sdk`, `pi-cursor-sdk`, and `@connectrpc/**` from asar.

- 2026-09-19 provider-streams: fresh worktree omits ignored `.memory`; read canonical checkout project context and will add a lane-specific note. Installed private node_modules with scripts disabled to avoid concurrent native/Electron builds.
- 2026-09-19 provider-streams: explicit `git add` reported ignored `.papercuts` even while staging its tracked file; used explicit force-add for required lane artifacts.
## 2026-09-19 — upgrade compaction checkpoint recovery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ object AidenProviderIconResolver {
val supportedSlugs = setOf(
"amazon-bedrock", "ant-ling", "anthropic", "apple-foundation-models",
"azure-openai-responses", "cerebras", "claude", "cloudflare-ai-gateway",
"cloudflare-workers-ai", "concentrate", "deepseek", "fireworks",
"cloudflare-workers-ai", "concentrate", "cursor", "deepseek", "fireworks",
"github-copilot", "google", "google-vertex", "grok", "groq",
"huggingface", "kimi-coding", "lmstudio", "minimax", "minimax-cn",
"mistral", "moonshotai", "moonshotai-cn", "nvidia", "ollama",
Expand Down
1 change: 1 addition & 0 deletions ios/AidenOnTheGo/Features/Shared/AidenProviderIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum AidenProviderIconResolver {
"cloudflare-ai-gateway",
"cloudflare-workers-ai",
"concentrate",
"cursor",
"deepseek",
"fireworks",
"github-copilot",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "cursor.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions ios/AidenOnTheGoTests/AidenChatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,7 @@ final class AidenChatTests: XCTestCase {
func testProviderIconResolverMatchesDesktopAliasesAndFallbackRules() {
XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "openai"), "openai")
XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "concentrate"), "concentrate")
XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "cursor"), "cursor")
XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "gemini"), "google")
XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "moonshot"), "moonshotai")
XCTAssertEqual(
Expand Down
1 change: 1 addition & 0 deletions main/services/concentrate-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ test("Concentrate is an Aiden built-in with bounded dynamic refresh and no secre

const models = registerAidenBuiltinProviders(createModels());
assert.equal(models.getProvider("concentrate")?.name, "Concentrate");
assert.equal(models.getProvider("cursor")?.name, "Cursor");
});

test("Concentrate rejects empty, malformed, and oversized catalogs", async () => {
Expand Down
3 changes: 2 additions & 1 deletion main/services/concentrate-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ThinkingLevelMap,
} from "@earendil-works/pi-ai";
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
import { registerCursorProvider } from "./cursor-provider.js";

export const CONCENTRATE_PROVIDER_ID = "concentrate";
export const CONCENTRATE_PROVIDER_NAME = "Concentrate";
Expand Down Expand Up @@ -179,5 +180,5 @@ export function concentrateProvider(fetchImpl: Fetch = fetch) {
/** Register Aiden-owned built-ins after Pi's pinned catalog is constructed. */
export function registerAidenBuiltinProviders(models: MutableModels): MutableModels {
models.setProvider(concentrateProvider());
return models;
return registerCursorProvider(models);
}
311 changes: 311 additions & 0 deletions main/services/cursor-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createAssistantMessageEventStream, createModels, type Model } from "@earendil-works/pi-ai";
import {
CURSOR_BASE_URL,
CURSOR_PROVIDER_ID,
__testUtils,
cursorProvider,
parseCursorModels,
} from "./cursor-provider.js";
import { currentCursorSession, runWithCursorSession } from "./cursor-session-binding.js";
import { registerAidenBuiltinProviders } from "./concentrate-provider.js";

async function drain(stream: AsyncIterable<unknown>): Promise<void> {
for await (const _event of stream) {
// Exhaust the stream so overlapping Cursor turns can settle.
}
}

test("Cursor catalog parsing keeps executable identities and reviewed capabilities", () => {
const models = parseCursorModels([
{
id: "composer-1.5",
name: "Composer 1.5",
reasoning: true,
thinkingLevelMap: {
off: "false",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: null,
max: null,
},
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_000,
},
{ id: "auto", name: "Auto", contextWindow: 128_000, maxTokens: 16_384 },
{ id: "composer-1.5", name: "duplicate is ignored", contextWindow: 1, maxTokens: 1 },
{ id: "x".repeat(257), name: "too long", contextWindow: 1, maxTokens: 1 },
]);

assert.deepEqual(
models.map((model) => model.id),
["composer-1.5", "auto"],
);
assert.deepEqual(models[0].input, ["text", "image"]);
assert.equal(models[0].api, "openai-completions");
assert.equal(models[0].provider, CURSOR_PROVIDER_ID);
assert.equal(models[0].baseUrl, CURSOR_BASE_URL);
assert.equal(models[0].reasoning, true);
assert.equal(models[0].thinkingLevelMap?.high, "high");
assert.deepEqual(models[1].input, ["text"]);
assert.equal(models[1].reasoning, false);
});

test("Cursor is an Aiden built-in with live SDK discovery and no secret projection", async (t) => {
t.after(() => __testUtils.resetTurnQueue());
const discovered: unknown[] = [];
const provider = cursorProvider({
discoverModels: async ({ apiKey, forceRefresh }) => {
discovered.push({ apiKey, forceRefresh });
return [
{
id: "composer-1.5",
name: "Composer 1.5",
contextWindow: 128_000,
maxTokens: 16_384,
},
];
},
});
const writes: Model<"openai-completions">[][] = [];
await provider.refreshModels?.({
allowNetwork: true,
force: true,
credential: { type: "api_key", key: "crsr_test_secret" },
stored: undefined,
publish: async (publication) => {
if (publication.persist) {
writes.push(publication.persist.models as Model<"openai-completions">[]);
}
publication.update?.();
return true;
},
signal: new AbortController().signal,
});

assert.deepEqual(discovered, [{ apiKey: "crsr_test_secret", forceRefresh: true }]);
assert.equal(provider.name, "Cursor");
assert.equal(provider.baseUrl, CURSOR_BASE_URL);
assert.equal(provider.auth.apiKey?.name, "Cursor API key");
assert.equal(provider.getModels()[0]?.id, "composer-1.5");
assert.equal(JSON.stringify(writes).includes("crsr_test_secret"), false);

const models = registerAidenBuiltinProviders(createModels());
assert.equal(models.getProvider("concentrate")?.name, "Concentrate");
assert.equal(models.getProvider("cursor")?.name, "Cursor");
});

test("Cursor rejects empty catalogs and missing API keys", async () => {
assert.throws(() => parseCursorModels([]), /no usable chat models/u);
assert.throws(() => parseCursorModels({ data: [] }), /invalid model catalog/u);

const provider = cursorProvider({
discoverModels: async () => {
throw new Error("should not discover without a key");
},
});
assert.ok(provider.refreshModels);
await assert.rejects(
provider.refreshModels({
allowNetwork: true,
stored: undefined,
publish: async (publication) => {
publication.update?.();
return true;
},
signal: new AbortController().signal,
}),
/needs an API key/u,
);
});

test("Cursor streams bind the Aiden workspace and serialize overlapping turns", async (t) => {
t.after(() => __testUtils.resetTurnQueue());
const order: string[] = [];
let releaseFirst: (() => void) | undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const sessions: Array<string | undefined> = [];
const provider = cursorProvider({
discoverModels: async () => [],
bindSession: async (binding) => {
sessions.push(binding.cwd);
},
streamSimple: (_model, _context, options) => {
const stream = createAssistantMessageEventStream();
const label = String(options?.headers?.["x-test"] ?? "turn");
queueMicrotask(() => {
void (async () => {
order.push(`start:${label}`);
if (label === "first") await firstGate;
stream.end();
order.push(`end:${label}`);
})();
});
return stream;
},
});

const model = {
id: "auto",
name: "Auto",
api: "openai-completions" as const,
provider: CURSOR_PROVIDER_ID,
baseUrl: CURSOR_BASE_URL,
reasoning: false,
input: ["text"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};

const first = runWithCursorSession(
{
cwd: "/workspace/one",
sessionId: "chat-1",
sessionFile: "aiden:chat-1",
projectTrusted: true,
},
() =>
provider.streamSimple(model, { messages: [] }, { headers: { "x-test": "first" } }),
);
const second = runWithCursorSession(
{
cwd: "/workspace/two",
sessionId: "chat-2",
sessionFile: "aiden:chat-2",
projectTrusted: true,
},
() =>
provider.streamSimple(model, { messages: [] }, { headers: { "x-test": "second" } }),
);

await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(order, ["start:first"]);
assert.deepEqual(sessions, ["/workspace/one"]);
releaseFirst?.();
await Promise.all([drain(first), drain(second)]);
assert.deepEqual(order, ["start:first", "end:first", "start:second", "end:second"]);
assert.deepEqual(sessions, ["/workspace/one", "/workspace/two"]);
assert.equal(currentCursorSession(), undefined);
});

test("Cursor skips a queued turn that was aborted before it started", async (t) => {
t.after(() => __testUtils.resetTurnQueue());
const order: string[] = [];
let releaseFirst: (() => void) | undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const sessions: string[] = [];
const provider = cursorProvider({
discoverModels: async () => [],
bindSession: async (binding) => {
sessions.push(binding.cwd);
},
streamSimple: (_model, _context, options) => {
const stream = createAssistantMessageEventStream();
const label = String(options?.headers?.["x-test"] ?? "turn");
queueMicrotask(() => {
void (async () => {
order.push(`start:${label}`);
if (label === "first") await firstGate;
stream.end();
order.push(`end:${label}`);
})();
});
return stream;
},
});

const model = {
id: "auto",
name: "Auto",
api: "openai-completions" as const,
provider: CURSOR_PROVIDER_ID,
baseUrl: CURSOR_BASE_URL,
reasoning: false,
input: ["text"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};

const first = runWithCursorSession(
{
cwd: "/workspace/one",
sessionId: "chat-1",
sessionFile: "aiden:chat-1",
projectTrusted: true,
},
() =>
provider.streamSimple(model, { messages: [] }, { headers: { "x-test": "first" } }),
);
const aborted = new AbortController();
const second = runWithCursorSession(
{
cwd: "/workspace/two",
sessionId: "chat-2",
sessionFile: "aiden:chat-2",
projectTrusted: true,
},
() =>
provider.streamSimple(model, { messages: [] }, {
headers: { "x-test": "second" },
signal: aborted.signal,
}),
);
aborted.abort();
releaseFirst?.();
const secondEvents: unknown[] = [];
await drain(first);
for await (const event of second) {
secondEvents.push(event);
}
assert.deepEqual(order, ["start:first", "end:first"]);
assert.deepEqual(sessions, ["/workspace/one"]);
assert.equal((secondEvents[0] as { type?: string; reason?: string })?.type, "error");
assert.equal((secondEvents[0] as { reason?: string })?.reason, "aborted");
});

test("Cursor binds an untrusted unbound session when no chat wrapper is present", async (t) => {
t.after(() => __testUtils.resetTurnQueue());
const sessions: Array<{ cwd: string; sessionFile: string; projectTrusted: boolean }> = [];
const provider = cursorProvider({
discoverModels: async () => [],
bindSession: async (binding) => {
sessions.push({
cwd: binding.cwd,
sessionFile: binding.sessionFile,
projectTrusted: binding.projectTrusted,
});
},
streamSimple: () => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => stream.end());
return stream;
},
});
const model = {
id: "auto",
name: "Auto",
api: "openai-completions" as const,
provider: CURSOR_PROVIDER_ID,
baseUrl: CURSOR_BASE_URL,
reasoning: false,
input: ["text"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};
await drain(provider.streamSimple(model, { messages: [] }));
assert.equal(sessions.length, 1);
assert.equal(sessions[0]?.sessionFile, "aiden:unbound");
assert.equal(sessions[0]?.projectTrusted, false);
});
Loading
Loading