Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 4d08ddba94 fix(app): prevent concurrent draft agent creation
React submit state is render-lagged, so callbacks entering in the same render can all start create requests. Claim the draft synchronously in the shared create-flow store before the first request begins.
2026-08-12 11:45:51 +00:00
Mohamed Boudra 2f6b585a3a fix(desktop): shut down cleanly with workspace terminals (#3268)
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
2026-08-12 13:00:55 +02:00
2c5c629db8 fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (#2704)
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite

Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.

Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.

Fixes #2586

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(usage): cover Cursor sqlite text and blob auth

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-12 12:25:04 +02:00
Mohamed Boudra 6ed364ffe2 Document per-execution Hub worktree branch names (#3267)
* docs(hub): document per-execution worktree branch names

Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.

The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.

* docs(hub): reshape the per-execution worktree branch docs

Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.

Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.

* docs(hub): make the per-execution worktree branch the only guidance

A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.

The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.
2026-08-12 18:20:08 +08:00
17 changed files with 384 additions and 159 deletions
+5 -3
View File
@@ -131,9 +131,11 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
Electron remains in the desktop dev runner's process group. Closing or stopping the
workspace-script terminal must terminate Electron with Metro; detaching Electron
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
@@ -88,6 +88,43 @@ describe("useDraftAgentCreateFlow", () => {
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("starts one create request when submit is invoked twice before React rerenders", async () => {
let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined;
const createRequest = vi.fn(
() =>
new Promise<{ agentId: string; result: { id: string } }>((resolve) => {
resolveCreate = resolve;
}),
);
const onCreateSuccess = vi.fn();
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
buildDraftAgent: (attempt) => ({ attempt }),
createRequest,
onCreateSuccess,
}),
);
const submission = { text: "build this", attachments: [], cwd: "/repo" };
let first: Promise<void>;
let duplicate: Promise<void>;
act(() => {
first = result.current.handleCreateFromInput(submission);
duplicate = result.current.handleCreateFromInput(submission);
});
await expect(duplicate!).rejects.toThrow();
expect(createRequest).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } });
await first!;
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = {
kind: "chat_history",
+16 -3
View File
@@ -84,6 +84,17 @@ interface CreateRequestContext {
cwd: string;
}
function assertCreateAvailable(input: {
draftId: string;
isSubmitting: boolean;
alreadyLoadingMessage: string;
}): void {
const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId];
if (input.isSubmitting || pending?.lifecycle === "active") {
throw new Error(input.alreadyLoadingMessage);
}
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
@@ -239,9 +250,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
throw new Error(t("composer.errors.alreadyLoading"));
}
assertCreateAvailable({
draftId,
isSubmitting,
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
});
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim();
@@ -15,3 +15,9 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
export function resolveChildKillTarget(pid, detached) {
return detached ? -pid : pid;
}
export function registerDevRunnerShutdownSignals({ signalSource, stop }) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
signalSource.on(signal, () => stop("SIGTERM"));
}
}
@@ -1,7 +1,23 @@
import { describe, expect, test } from "vitest";
import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
import { readFileSync } from "node:fs";
import {
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
describe("desktop dev process ownership", () => {
test("makes the signal-handling runner the workspace terminal process", () => {
const paseoConfig = JSON.parse(
readFileSync(new URL("../../../paseo.json", import.meta.url), "utf8"),
);
const devScript = readFileSync(new URL("./dev.sh", import.meta.url), "utf8");
expect(paseoConfig.scripts.desktop.command).toContain("exec ./packages/desktop/scripts/dev.sh");
expect(devScript).toContain('npm --prefix "$DESKTOP_DIR" run build:main');
expect(devScript).toContain('exec node "$SCRIPT_DIR/dev-runner.mjs"');
});
test("keeps Electron in the runner process group", () => {
const options = createElectronSpawnOptions({
env: { PATH: "/usr/bin" },
@@ -23,4 +39,24 @@ describe("desktop dev process ownership", () => {
expect(resolveChildKillTarget(42, true)).toBe(-42);
expect(resolveChildKillTarget(42, false)).toBe(42);
});
test("stops children when the owning terminal hangs up", () => {
const listeners = new Map();
const receivedSignals = [];
registerDevRunnerShutdownSignals({
signalSource: {
on(signal, listener) {
listeners.set(signal, listener);
},
},
stop(signal) {
receivedSignals.push(signal);
},
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGHUP")();
expect(receivedSignals).toEqual(["SIGTERM"]);
});
});
+6 -3
View File
@@ -4,7 +4,11 @@ import path from "node:path";
import { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
import {
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
@@ -159,8 +163,7 @@ function canConnect(port, host) {
});
}
process.on("SIGINT", () => stopAll("SIGTERM"));
process.on("SIGTERM", () => stopAll("SIGTERM"));
registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll });
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
cwd: appDir,
+1 -1
View File
@@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
npm run build:main
npm --prefix "$DESKTOP_DIR" run build:main
echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev"
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import {
createQuitLifecycle,
registerExternalQuitSignals,
shouldStopDesktopManagedDaemonOnQuit,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./quit-lifecycle";
@@ -29,6 +30,25 @@ function waitForQuitLifecycle(): Promise<void> {
}
describe("quit-lifecycle", () => {
it("turns external termination signals into one Electron quit", () => {
const listeners = new Map<NodeJS.Signals, () => void>();
const quits: string[] = [];
registerExternalQuitSignals({
signals: {
on: (signal, listener) => {
listeners.set(signal, listener);
},
},
quit: () => quits.push("quit"),
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGTERM")?.();
listeners.get("SIGHUP")?.();
expect(quits).toEqual(["quit"]);
});
it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
@@ -14,6 +14,10 @@ interface BeforeQuitApp {
exit(code: number): void;
}
interface ExternalQuitSignalSource {
on(signal: NodeJS.Signals, listener: () => void): unknown;
}
interface QuitLifecycle {
handleBeforeQuit(event: BeforeQuitEvent): void;
handleBeforeQuitForUpdate(): void;
@@ -31,6 +35,23 @@ export interface StopOnQuitDeps {
showShutdownFeedback: () => void;
}
export function registerExternalQuitSignals({
signals,
quit,
}: {
signals: ExternalQuitSignalSource;
quit: () => void;
}): void {
let quitRequested = false;
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) {
signals.on(signal, () => {
if (quitRequested) return;
quitRequested = true;
quit();
});
}
}
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
return !settings.daemon.keepRunningAfterQuit;
}
+2
View File
@@ -83,6 +83,7 @@ import {
} from "./daemon/daemon-manager.js";
import {
createQuitLifecycle,
registerExternalQuitSignals,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js";
@@ -1081,6 +1082,7 @@ const quitLifecycle = createQuitLifecycle({
// electron-updater forwards this event through Electron's built-in autoUpdater.
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
app.on("before-quit", quitLifecycle.handleBeforeQuit);
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
+35 -88
View File
@@ -366,10 +366,6 @@ type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type AgentUpdatesFilter = FetchAgentsRequestFilter;
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
interface CreatedAgentRequest {
agent: AgentSnapshotPayload;
}
interface ResolvedSessionCreateAgentIntent {
config: AgentSessionConfig;
intent: CreateAgentIntent;
@@ -680,10 +676,6 @@ export class Session {
private readonly hubExecutionController: HubExecutionController | null;
private readonly workspaceScripts: WorkspaceScriptsService;
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private readonly inFlightAgentCreationsByClientMessageId = new Map<
string,
Promise<CreatedAgentRequest>
>();
constructor(options: SessionOptions) {
const {
@@ -3110,86 +3102,10 @@ export class Session {
* Handle create agent request
*/
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
const clientMessageId = msg.clientMessageId?.trim();
const existingCreation = clientMessageId
? this.inFlightAgentCreationsByClientMessageId.get(clientMessageId)
: undefined;
if (existingCreation) {
try {
const created = await existingCreation;
this.emitCreatedAgentResponse(msg.requestId, created.agent);
} catch (error) {
this.emitFailedAgentCreateResponse(msg.requestId, error);
}
return;
}
const creation = this.createAgentForRequest(msg);
if (clientMessageId) {
this.inFlightAgentCreationsByClientMessageId.set(clientMessageId, creation);
}
try {
const created = await creation;
this.emitCreatedAgentResponse(msg.requestId, created.agent);
} catch (error) {
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
this.emitFailedAgentCreateResponse(msg.requestId, wireError);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
} finally {
if (
clientMessageId &&
this.inFlightAgentCreationsByClientMessageId.get(clientMessageId) === creation
) {
this.inFlightAgentCreationsByClientMessageId.delete(clientMessageId);
}
}
}
private emitCreatedAgentResponse(
requestId: string | undefined,
agent: AgentSnapshotPayload,
): void {
if (!requestId) return;
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: agent.id,
requestId,
agent,
},
});
}
private emitFailedAgentCreateResponse(requestId: string | undefined, error: unknown): void {
if (!requestId) return;
const wireError = toWorktreeWireError(error);
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
private async createAgentForRequest(
msg: CreateAgentRequestMessage,
): Promise<CreatedAgentRequest> {
const {
config,
worktreeName,
requestId,
initialPrompt,
clientMessageId,
outputSchema,
@@ -3289,19 +3205,50 @@ export class Session {
agentId: snapshot.id,
createdWorktree,
});
const agentPayload = await this.buildAgentPayload(liveSnapshot);
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: liveSnapshot.id,
requestId,
agent: agentPayload,
},
});
}
this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider },
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
return { agent: agentPayload };
} catch (error) {
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
createdWorktree: createdWorktreeForCleanup,
createdAgentId,
});
throw error;
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
}
}
@@ -872,7 +872,7 @@ test("client heartbeat clears attention for the focused terminal", async () => {
});
});
test("create_agent_request deduplicates concurrent draft submission retries", async () => {
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
try {
const parent = path.join(workdir, "parent");
@@ -981,26 +981,14 @@ test("create_agent_request deduplicates concurrent draft submission retries", as
terminalManager: null,
}),
);
await Promise.all([
session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child-1",
clientMessageId: "draft-submission-1",
config: { provider: "codex", cwd: child },
attachments: [],
}),
session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child-2",
clientMessageId: "draft-submission-1",
config: { provider: "codex", cwd: child },
attachments: [],
}),
]);
await session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child",
config: { provider: "codex", cwd: child },
attachments: [],
});
const createdAgents = agentManager.listAgents();
expect(createdAgents).toHaveLength(1);
const [createdAgent] = createdAgents;
const [createdAgent] = agentManager.listAgents();
expect(createdAgent?.cwd).toBe(child);
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
expect(createdWorkspace).not.toBeNull();
@@ -1019,22 +1007,10 @@ test("create_agent_request deduplicates concurrent draft submission retries", as
projectKey: createdWorkspace!.projectId,
checkout: { cwd: child },
});
expect(
emitted.filter((message) => message.type === "status").map((message) => message.payload),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "agent_created",
requestId: "req-create-child-1",
agent: expect.objectContaining({ cwd: child }),
}),
expect.objectContaining({
status: "agent_created",
requestId: "req-create-child-2",
agent: expect.objectContaining({ cwd: child }),
}),
]),
);
expect(findByType(emitted, "status")?.payload).toMatchObject({
status: "agent_created",
agent: { cwd: child },
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
@@ -1,8 +1,6 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
@@ -16,8 +14,25 @@ import {
unavailableUsage,
} from "../usage.js";
const execFileAsync = promisify(execFile);
const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the
// access token as a plain JWT string under `cursorAuth/accessToken`; older builds
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus";
// @types/node@20 predates the node:sqlite typings; declare the slice we use.
interface CursorStateStatement {
get(...params: unknown[]): Record<string, unknown> | undefined;
}
interface CursorStateDatabase {
prepare(sql: string): CursorStateStatement;
close(): void;
}
interface NodeSqliteModule {
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
}
const CursorBillingCycleTimestampSchema = z.preprocess(
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
@@ -47,6 +62,7 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
interface CursorQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
homeDir?: string;
}
function parseCursorBillingCycleTimestamp(
@@ -70,14 +86,38 @@ function centsToDollars(value: number | null): number | null {
return value === null ? null : value / 100;
}
async function readCursorTokenFromSqlite(): Promise<string | null> {
function readItemTableValue(db: CursorStateDatabase, key: string): string | null {
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
const value = row?.["value"];
if (typeof value === "string") return value;
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return null;
}
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
if (modern) return modern;
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
if (legacy) {
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
if (parsed.accessToken) return parsed.accessToken;
} catch {
// ignore a malformed legacy blob
}
}
return null;
}
async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> {
const dbPaths: string[] = [];
if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
}
dbPaths.push(
join(
homedir(),
homeDir,
"Library",
"Application Support",
"Cursor",
@@ -86,22 +126,32 @@ async function readCursorTokenFromSqlite(): Promise<string | null> {
"state.vscdb",
),
);
dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
const sqliteSpecifier: string = "node:sqlite";
let sqlite: NodeSqliteModule;
try {
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
} catch (err) {
logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb");
return null; // runtime without node:sqlite
}
for (const path of dbPaths) {
if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try {
const { stdout } = await execFileAsync(
"sqlite3",
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
{ timeout: CURSOR_SQLITE_TIMEOUT_MS },
);
if (stdout) {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
if (parsed.accessToken) return parsed.accessToken;
}
} catch {
continue;
db = new sqlite.DatabaseSync(path, { readOnly: true });
const token = cursorTokenFromDb(db);
if (token) return token;
} catch (err) {
// Locked/permission/corrupt/schema failures all land here; log so an
// unavailable Cursor card is diagnosable, then try the next candidate.
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb");
} finally {
db?.close();
}
}
return null;
@@ -113,17 +163,19 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
private readonly homeDir: string;
constructor(options: CursorQuotaProviderOptions) {
this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir ?? homedir();
}
async fetchUsage(): Promise<ProviderUsage> {
const token =
process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite());
(await readCursorTokenFromSqlite(this.homeDir, this.logger));
if (!token) return unavailableUsage(this);
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -58,6 +59,31 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec
);
}
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
const testRequire = createRequire(import.meta.url);
interface TestSqliteDb {
exec(sql: string): void;
prepare(sql: string): { run(...params: unknown[]): void };
close(): void;
}
// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the
// real layouts: a plain modern token or the legacy JSON object.
function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
const { DatabaseSync } = testRequire("node:sqlite") as {
DatabaseSync: new (path: string) => TestSqliteDb;
};
const db = new DatabaseSync(join(dir, "state.vscdb"));
db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
for (const [key, value] of Object.entries(rows)) {
insert.run(key, value);
}
db.close();
}
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
mkdirSync(join(home, ".grok"), { recursive: true });
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
@@ -372,6 +398,7 @@ describe("real provider usage fetchers", () => {
platform?: typeof process.platform;
keychain?: () => Promise<unknown | null>;
kimiHomeDir?: string;
cursorHomeDir?: string;
miniMaxConfigPath?: string;
miniMaxCredentialsPath?: string;
} = {},
@@ -392,7 +419,11 @@ describe("real provider usage fetchers", () => {
}),
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
homeDir: options.cursorHomeDir,
}),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({
logger,
@@ -702,6 +733,75 @@ describe("real provider usage fetchers", () => {
});
});
it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => {
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" });
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: {
totalSpend: "1500",
includedSpend: "1000",
bonusSpend: "500",
remaining: "2500",
limit: "4000",
},
billingCycleStart: "2026-01-14T12:42:14.000Z",
billingCycleEnd: "2026-02-14T12:42:14.000Z",
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_state_jwt");
expect(cursor).toMatchObject({
status: "available",
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
});
});
it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => {
writeCursorStateDb(homeDir, {
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"),
});
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
billingCycleStart: null,
billingCycleEnd: null,
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_legacy_jwt");
expect(cursor.status).toBe("available");
});
it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "state.vscdb"), "not a sqlite database");
const logger = createLogger();
const provider = new CursorQuotaProvider({
logger,
fetch: (() => {
throw new Error("usage API should not be called without a token");
}) as unknown as typeof fetch,
homeDir,
});
const usage = await provider.fetchUsage();
expect(usage.status).toBe("unavailable");
expect((logger as unknown as { debug: ReturnType<typeof vi.fn> }).debug).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("state.vscdb") }),
expect.stringContaining("Failed to read Cursor token"),
);
});
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
process.env["ZAI_API_KEY"] = "zai_test_token";
fetchApi = mockFetch(
+1 -1
View File
@@ -19,7 +19,7 @@
},
"desktop": {
"type": "service",
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop"
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} exec ./packages/desktop/scripts/dev.sh"
},
"ios-simulator": {
"type": "service",
+9 -1
View File
@@ -72,10 +72,18 @@ environments:
cwd: /workspace/project
worktree:
mode: branch-off
newBranch: paseo/review
newBranch: trigger-${{ paseo.execution.id }}
base: origin/main
```
`newBranch` is a branch-name string. Embed `${{ paseo.execution.id }}`, which renders the execution's UUID, so every execution branches off `base` on its own branch and keeps it when Hub retries or recovers that execution.
One execution is one step run, so two steps selecting the same environment get separate branches.
`${{ paseo.execution.id }}` is the only expression `newBranch` accepts. `paseo.prompt`, `paseo.context`, `paseo.inputs.*`, `values.*`, `steps.<id>.outputs.*`, and provider event fields are unavailable here, and each one fails bundle activation at the authored field, such as `.paseo/hub.yml.environments.review.worktree.newBranch`.
`${{ paseo.execution.id }}` fails activation the same way anywhere else in a bundle. `branch` and `prNumber` take literal values.
An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
### Named agents
+4 -2
View File
@@ -80,11 +80,13 @@ To keep executions off your working tree, add a worktree:
```yaml
worktree:
mode: branch-off
newBranch: hub/investigation
newBranch: trigger-${{ paseo.execution.id }}
base: origin/main
```
See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
`${{ paseo.execution.id }}` renders the execution's UUID, so every execution gets its own branch off `origin/main`.
[Environment fields](/docs/hub/configuration/hub-yml#environments) lists what `newBranch` accepts. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
## What Hub owns