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
15 changed files with 337 additions and 35 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 a new worktree instance to the parent desktop instance's profile or single-instance
lock. lock.
Electron remains in the desktop dev runner's process group. Closing or stopping the The desktop workspace script `exec`s the dev runner so the terminal owns the runner
workspace-script terminal must terminate Electron with Metro; detaching Electron PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
leaves an orphan holding the worktree's single-instance lock and broken output pipes. 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 With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with: transition, and 751-pixel settings split with:
@@ -88,6 +88,43 @@ describe("useDraftAgentCreateFlow", () => {
expect(onCreateSuccess).toHaveBeenCalledTimes(1); 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 () => { it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = { const attachment = {
kind: "chat_history", kind: "chat_history",
+16 -3
View File
@@ -84,6 +84,17 @@ interface CreateRequestContext {
cwd: string; 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> { interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string; draftId: string;
getPendingServerId: () => string | null; getPendingServerId: () => string | null;
@@ -239,9 +250,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback( const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => { async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) { assertCreateAvailable({
throw new Error(t("composer.errors.alreadyLoading")); draftId,
} isSubmitting,
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
});
dispatch({ type: "DRAFT_SET_ERROR", message: "" }); dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim(); const trimmedPrompt = text.trim();
@@ -15,3 +15,9 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
export function resolveChildKillTarget(pid, detached) { export function resolveChildKillTarget(pid, detached) {
return detached ? -pid : pid; 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 { 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", () => { 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", () => { test("keeps Electron in the runner process group", () => {
const options = createElectronSpawnOptions({ const options = createElectronSpawnOptions({
env: { PATH: "/usr/bin" }, env: { PATH: "/usr/bin" },
@@ -23,4 +39,24 @@ describe("desktop dev process ownership", () => {
expect(resolveChildKillTarget(42, true)).toBe(-42); expect(resolveChildKillTarget(42, true)).toBe(-42);
expect(resolveChildKillTarget(42, false)).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 { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; 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"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
@@ -159,8 +163,7 @@ function canConnect(port, host) {
}); });
} }
process.on("SIGINT", () => stopAll("SIGTERM")); registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll });
process.on("SIGTERM", () => stopAll("SIGTERM"));
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
cwd: appDir, cwd: appDir,
+1 -1
View File
@@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
npm run build:main npm --prefix "$DESKTOP_DIR" run build:main
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev" echo " Paseo Desktop Dev"
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { import {
createQuitLifecycle, createQuitLifecycle,
registerExternalQuitSignals,
shouldStopDesktopManagedDaemonOnQuit, shouldStopDesktopManagedDaemonOnQuit,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./quit-lifecycle"; } from "./quit-lifecycle";
@@ -29,6 +30,25 @@ function waitForQuitLifecycle(): Promise<void> {
} }
describe("quit-lifecycle", () => { 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", () => { it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
@@ -14,6 +14,10 @@ interface BeforeQuitApp {
exit(code: number): void; exit(code: number): void;
} }
interface ExternalQuitSignalSource {
on(signal: NodeJS.Signals, listener: () => void): unknown;
}
interface QuitLifecycle { interface QuitLifecycle {
handleBeforeQuit(event: BeforeQuitEvent): void; handleBeforeQuit(event: BeforeQuitEvent): void;
handleBeforeQuitForUpdate(): void; handleBeforeQuitForUpdate(): void;
@@ -31,6 +35,23 @@ export interface StopOnQuitDeps {
showShutdownFeedback: () => void; 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 { export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
return !settings.daemon.keepRunningAfterQuit; return !settings.daemon.keepRunningAfterQuit;
} }
+2
View File
@@ -83,6 +83,7 @@ import {
} from "./daemon/daemon-manager.js"; } from "./daemon/daemon-manager.js";
import { import {
createQuitLifecycle, createQuitLifecycle,
registerExternalQuitSignals,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js"; } from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.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. // electron-updater forwards this event through Electron's built-in autoUpdater.
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
app.on("before-quit", quitLifecycle.handleBeforeQuit); app.on("before-quit", quitLifecycle.handleBeforeQuit);
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
@@ -1,8 +1,6 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
@@ -16,8 +14,25 @@ import {
unavailableUsage, unavailableUsage,
} from "../usage.js"; } from "../usage.js";
const execFileAsync = promisify(execFile); // Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the
const CURSOR_SQLITE_TIMEOUT_MS = 2_000; // 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( const CursorBillingCycleTimestampSchema = z.preprocess(
(value) => (typeof value === "string" || typeof value === "number" ? value : null), (value) => (typeof value === "string" || typeof value === "number" ? value : null),
@@ -47,6 +62,7 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
interface CursorQuotaProviderOptions { interface CursorQuotaProviderOptions {
logger: Logger; logger: Logger;
fetch?: ProviderApiFetch; fetch?: ProviderApiFetch;
homeDir?: string;
} }
function parseCursorBillingCycleTimestamp( function parseCursorBillingCycleTimestamp(
@@ -70,14 +86,38 @@ function centsToDollars(value: number | null): number | null {
return value === null ? null : value / 100; 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[] = []; const dbPaths: string[] = [];
if (process.env["APPDATA"]) { if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
} }
dbPaths.push( dbPaths.push(
join( join(
homedir(), homeDir,
"Library", "Library",
"Application Support", "Application Support",
"Cursor", "Cursor",
@@ -86,22 +126,32 @@ async function readCursorTokenFromSqlite(): Promise<string | null> {
"state.vscdb", "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) { for (const path of dbPaths) {
if (!existsSync(path)) continue; if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try { try {
const { stdout } = await execFileAsync( db = new sqlite.DatabaseSync(path, { readOnly: true });
"sqlite3", const token = cursorTokenFromDb(db);
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"], if (token) return token;
{ timeout: CURSOR_SQLITE_TIMEOUT_MS }, } catch (err) {
); // Locked/permission/corrupt/schema failures all land here; log so an
if (stdout) { // unavailable Cursor card is diagnosable, then try the next candidate.
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim())); logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb");
if (parsed.accessToken) return parsed.accessToken; } finally {
} db?.close();
} catch {
continue;
} }
} }
return null; return null;
@@ -113,17 +163,19 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
private readonly logger: Logger; private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch; private readonly fetchApi: ProviderApiFetch;
private readonly homeDir: string;
constructor(options: CursorQuotaProviderOptions) { constructor(options: CursorQuotaProviderOptions) {
this.logger = options.logger; this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch; this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir ?? homedir();
} }
async fetchUsage(): Promise<ProviderUsage> { async fetchUsage(): Promise<ProviderUsage> {
const token = const token =
process.env["CURSOR_ACCESS_TOKEN"] || process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] || process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite()); (await readCursorTokenFromSqlite(this.homeDir, this.logger));
if (!token) return unavailableUsage(this); if (!token) return unavailableUsage(this);
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 { function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
mkdirSync(join(home, ".grok"), { recursive: true }); mkdirSync(join(home, ".grok"), { recursive: true });
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
@@ -372,6 +398,7 @@ describe("real provider usage fetchers", () => {
platform?: typeof process.platform; platform?: typeof process.platform;
keychain?: () => Promise<unknown | null>; keychain?: () => Promise<unknown | null>;
kimiHomeDir?: string; kimiHomeDir?: string;
cursorHomeDir?: string;
miniMaxConfigPath?: string; miniMaxConfigPath?: string;
miniMaxCredentialsPath?: string; miniMaxCredentialsPath?: string;
} = {}, } = {},
@@ -392,7 +419,11 @@ describe("real provider usage fetchers", () => {
}), }),
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
new CopilotQuotaProvider({ logger, 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 ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({ new GrokQuotaProvider({
logger, 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 () => { it("fetches Z.ai usage from ZAI_API_KEY", async () => {
process.env["ZAI_API_KEY"] = "zai_test_token"; process.env["ZAI_API_KEY"] = "zai_test_token";
fetchApi = mockFetch( fetchApi = mockFetch(
+1 -1
View File
@@ -19,7 +19,7 @@
}, },
"desktop": { "desktop": {
"type": "service", "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": { "ios-simulator": {
"type": "service", "type": "service",
+9 -1
View File
@@ -72,10 +72,18 @@ environments:
cwd: /workspace/project cwd: /workspace/project
worktree: worktree:
mode: branch-off mode: branch-off
newBranch: paseo/review newBranch: trigger-${{ paseo.execution.id }}
base: origin/main 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. An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
### Named agents ### Named agents
+4 -2
View File
@@ -80,11 +80,13 @@ To keep executions off your working tree, add a worktree:
```yaml ```yaml
worktree: worktree:
mode: branch-off mode: branch-off
newBranch: hub/investigation newBranch: trigger-${{ paseo.execution.id }}
base: origin/main 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 ## What Hub owns