From e4fc8bf1e701eb83bd8e644f66bef5d1d61e0c88 Mon Sep 17 00:00:00 2001 From: Arjit Jaiswal Date: Wed, 16 Sep 2026 01:11:31 -0400 Subject: [PATCH 1/3] Add Cursor CLI external workers --- CHANGES.md | 2 + NOTICE.md | 2 + README.md | 2 + docs/reference.md | 8 +- plugins/pstack/skills/arena/SKILL.md | 2 +- .../poteto-mode/references/codex-tools.md | 2 + .../references/provider-dispatch.md | 28 ++- .../skills/poteto-mode/scripts/runner/cli.ts | 4 +- .../poteto-mode/scripts/runner/commands.ts | 14 ++ .../poteto-mode/scripts/runner/cursor.test.ts | 167 ++++++++++++++++++ .../poteto-mode/scripts/runner/cursor.ts | 26 +++ .../scripts/runner/parse-output.ts | 31 ++++ .../skills/poteto-mode/scripts/runner/run.ts | 29 ++- .../poteto-mode/scripts/runner/types.ts | 4 +- plugins/pstack/skills/setup-pstack/SKILL.md | 24 ++- 15 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts diff --git a/CHANGES.md b/CHANGES.md index 92906368..5cfc5b10 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -392,3 +392,5 @@ If you want a clean re-port (e.g. when upstream releases v0.2.0), the rebuild re - Upstream deslop: [cursor/plugins/cursor-team-kit/skills/deslop @ e46364b](https://github.com/cursor/plugins/tree/e46364b8be46000b7df0f260550cd712afbb8d36/cursor-team-kit/skills/deslop) — MIT, (c) 2026 Cursor. - babysit: independently authored; workflow informed by Cursor's public `/babysit` behavior — no code or prose copied. - Inspected for prior-art decisions: [v1truv1us/ai-eng-system](https://github.com/v1truv1us/ai-eng-system) (namespaces pstack under `pstack/` but keeps Cursor refs intact); [Evan-Kim2028/agent-fleet](https://github.com/Evan-Kim2028/agent-fleet) (vendors pstack under `base-kit/pstack/`, same posture). + +- Add opt-in Cursor external workers through `cursor-agent`, exact model slugs and `@default`, private per-run permissions, JSON receipts, and setup probes. Parent completion accepts explicitly pinned Cursor arguments without claiming served-model verification. Cursor project hooks/rules and recursive subagents remain provider limitations. diff --git a/NOTICE.md b/NOTICE.md index 0ec8a23e..b5ebef6c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -57,3 +57,5 @@ Files authored for this port (not derived from upstream): - `README.md` - `CHANGES.md` - `LICENSE-cursor-team-kit` (copied verbatim from upstream cursor-team-kit MIT) + +- Cursor external-provider support in `skills/poteto-mode/scripts/runner/` and its setup/dispatch documentation is independently authored for this port against Cursor CLI public interfaces; no Cursor CLI implementation code is copied. diff --git a/README.md b/README.md index 64901115..f3397349 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ Both apps read the same pstack skills. Only the way they start those skills and | Other models | Codex and Grok run through their signed-in command-line tools. | Claude and Grok run through their signed-in command-line tools. | | Skills and workflows | Shared with Codex. | Shared with Claude Code. | +Cursor models can also join as optional external workers through the signed-in `cursor-agent` CLI. Choose an exact slug from `cursor-agent models` and configure `cursor:@default` with `/setup-pstack`; the CLI has no separate effort flag. This uses Cursor's account access and limits, and does not make Cursor a parent harness for this port. + Grok can take part in a multi-model review. You cannot use Grok as the main app running pstack. ## Learn from the original diff --git a/docs/reference.md b/docs/reference.md index 23015146..ad53f159 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,7 +2,7 @@ This page contains the full skill, dependency, runtime, and porting reference. For the plain-English introduction and quick start, see the [main README](../README.md). -[Poteto](https://x.com/poteto)'s [pstack](https://github.com/cursor/plugins/tree/main/pstack), adapted to run in Claude Code and Codex without Cursor. One shared skill tree serves both harnesses; Grok remains available as a model-provider lane. Version 1.4.1 is synced to Cursor pstack v0.15.1 at `f8abeddd1862dc73704e3d719dd73df0d51b8c71`. See [UPSTREAM.md](../UPSTREAM.md) for the exact sync contract. +[Poteto](https://x.com/poteto)'s [pstack](https://github.com/cursor/plugins/tree/main/pstack), adapted to run in Claude Code and Codex without Cursor. One shared skill tree serves both harnesses; Grok and Cursor remain available as external model-provider lanes. Version 1.4.1 is synced to Cursor pstack v0.15.1 at `f8abeddd1862dc73704e3d719dd73df0d51b8c71`. See [UPSTREAM.md](../UPSTREAM.md) for the exact sync contract. Original by Lauren Tan. This distribution builds on Michael Denyer's [pstack-claude](https://github.com/michael-denyer/pstack-claude) port and retains its history and MIT attribution. It imports seven MIT-licensed skills from [cursor-team-kit](https://github.com/cursor/plugins/tree/main/cursor-team-kit): `deslop`, `thermo-nuclear-code-quality-review`, `make-pr-easy-to-review`, `fix-ci`, `fix-merge-conflicts`, `get-pr-comments`, `what-did-i-get-done`. @@ -227,3 +227,9 @@ MIT. Three upstream LICENSE files are preserved: - [LICENSE](../LICENSE) — pstack (Lauren Tan) - [LICENSE-cursor-team-kit](../LICENSE-cursor-team-kit) — Cursor (covers the `deslop` and `thermo-nuclear-code-quality-review` skills) - [LICENSE-superpowers](../LICENSE-superpowers) — superpowers, Jesse Vincent (covers the vendored `hooks/run-hook.cmd`) + +### Optional Cursor workers + +`cursor:@default` starts `cursor-agent` from either parent; discover slugs with `cursor-agent models`. Setup probes only assigned optional Cursor models. The adapter preserves the exact selection, successful JSON result, session ID, and optional token usage. A missing model report is recorded as pinned-argv evidence, not verified served-model identity. + +Cursor workers use their own tools and do not inherit the parent's MCP tools or conversation. Supply complete task context. Read-only uses ask mode with shell/write denied; writers need a dedicated workspace and sandbox. A private per-run configuration isolates global permission settings without copying credentials. Project configuration and startup hooks can still load, and recursive Cursor subagents cannot currently be disabled by a supported flag. See [provider dispatch](../plugins/pstack/skills/poteto-mode/references/provider-dispatch.md) for the full contract. diff --git a/plugins/pstack/skills/arena/SKILL.md b/plugins/pstack/skills/arena/SKILL.md index 0e3b5f38..3429751d 100644 --- a/plugins/pstack/skills/arena/SKILL.md +++ b/plugins/pstack/skills/arena/SKILL.md @@ -35,7 +35,7 @@ Start all N lanes in one fan-out phase through the provider-dispatch contract. N Each rationale names the alternatives the candidate considered and what it rejected. -An external lane counts only when its receipt says `complete` and carries either a matching `provider-report` or Codex's exact `pinned-argv` evidence; a native lane counts when its tool transcript returns the assigned model's result. If a candidate fails, proceed with N-1 and note the exact dropout in the synthesis record. Never replace it with another provider silently. +An external lane counts only when it satisfies the shared provider-dispatch completion contract, including matching `provider-report` or permitted exact `pinned-argv` evidence; a native lane counts when its tool transcript returns the assigned model's result. If a candidate fails, proceed with N-1 and note the exact dropout in the synthesis record. Never replace it with another provider silently. ## Phase C: Cross-judge diff --git a/plugins/pstack/skills/poteto-mode/references/codex-tools.md b/plugins/pstack/skills/poteto-mode/references/codex-tools.md index b967458d..b5b130ec 100644 --- a/plugins/pstack/skills/poteto-mode/references/codex-tools.md +++ b/plugins/pstack/skills/poteto-mode/references/codex-tools.md @@ -62,3 +62,5 @@ Some triggers name skills that ship with Claude Code, not pstack. They do not ex ## Instructions file Where a pstack skill says "your instructions file", on Codex that is `AGENTS.md` (project root, plus `~/.codex/AGENTS.md` global). On Claude Code it is `CLAUDE.md`. + +Cursor descriptors (`cursor:@default`) always use the external runner through `cursor-agent`. Follow the optional Cursor contract in `provider-dispatch.md`; keep MCP-dependent roles native. diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index 74ab90da..b36ec5e7 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -15,10 +15,14 @@ pstack model choices are provider-qualified descriptors: | grok | grok-4.6-fast-xhigh | grok | grok-4.6 | xhigh | low medium high xhigh max | - | | opus | opus | claude | opus | xhigh | low medium high xhigh max | opus | -The allowed effort universe is exactly `low`, `medium`, `high`, `xhigh`, `max`. First-run requested efforts are the Default effort cell of each row. A Claude-native agent stem of `-` means the family has no Claude-native agent. Otherwise the shipped agent name is `pstack--`. +For the baseline matrix, the allowed effort universe is exactly `low`, `medium`, `high`, `xhigh`, `max`. First-run requested efforts are the Default effort cell of each row. A Claude-native agent stem of `-` means the family has no Claude-native agent. Otherwise the shipped agent name is `pstack--`. `fable` and `opus` are Claude Code's rolling aliases. Claude resolves each alias to the latest available family revision. A runner receipt keeps the requested alias in `model` and the concrete provider-reported revision in `reportedModel`; verification accepts only a numeric `claude-fable-*` or `claude-opus-*` revision from the matching family. +## Optional Cursor models + +Cursor is an external provider from both parents. Install and authenticate `cursor-agent`, run `cursor-agent models`, and choose an exact available slug as `cursor:@default` (for example, `cursor:composer-2.5@default` when listed). `default` means no separate effort flag is available; select any reasoning variant by its exact model slug. Do not translate Claude/Codex/Grok slugs into Cursor slugs or use Cursor's `auto` selector. Model availability and subscription limits remain Cursor's responsibility. Adding a Cursor lane does not alter the four baseline families. + ## Read-time normalization Normalize configured descriptors before matching them to the matrix or choosing a route. If a provider-qualified Claude model starts with `claude-fable-` or `claude-opus-` and its remaining revision contains only digits and hyphens, replace that model component in memory with `fable` or `opus`. Preserve provider, effort, role, and lane order. Use only the normalized descriptor for native dispatch or runner argv. Never pass the versioned predecessor to Claude. @@ -31,10 +35,10 @@ This read-time rule makes an older installed sheet use the latest family revisio The top-level harness resolves the route once. A child receives an assigned provider, model, effort, access mode, prompt, working directory, and output path. A child never detects the harness, chooses a provider, or launches another model. Environment markers may corroborate the top-level harness before fan-out, but nested processes inherit parent markers and must not use them for routing. -| Parent | `claude:*` | `codex:*` | `grok:*` | -|---|---|---|---| -| Claude Code | native `Agent` | external runner | external runner | -| Codex | external runner | native `spawn_agent` | external runner | +| Parent | `claude:*` | `codex:*` | `grok:*` | `cursor:*` | +|---|---|---|---|---| +| Claude Code | native `Agent` | external runner | external runner | external runner | +| Codex | external runner | native `spawn_agent` | external runner | external runner | `inherit-parent` and `auto` remain aliases. They use the parent's current model and effort through its native subagent primitive. In a panel they still consume one lane, but they reduce provider diversity; say so in the synthesis record. @@ -54,9 +58,9 @@ The launcher lives at `skills/poteto-mode/scripts/runner/pstack-runner` under th ```text pstack-runner \ --parent \ - --provider \ + --provider \ --model \ - --effort \ + --effort \ --mode \ --prompt \ --cwd \ @@ -82,6 +86,12 @@ The runner and its preflight have no implicit timeout. Do not invent a duration Read-only mode maps to Claude plan mode with project-only settings and an explicit tool list, Codex's read-only sandbox, and Grok plan mode plus its `read-only` sandbox and read-oriented tool list. Grok's built-in read-only profile deliberately keeps its own state and system temporary directories writable, so point a read-only Grok lane at the actual checkout rather than a worktree under `/tmp`, `/var/tmp`, or the host's temporary directory. `isolated-write` maps to Claude `acceptEdits` with project-only settings, Codex `workspace-write`, and Grok `acceptEdits` plus its `workspace` sandbox and write-capable tool list. Give every writer only a dedicated worktree or output directory. Never route a writer into the primary checkout. +Cursor uses the explicit `cursor-agent` executable, not `agent` (which can name another CLI). Preflight requires `cursor-agent status --format json` to return `isAuthenticated: true`; setup also performs a real model probe because login alone does not prove model access. The runner sends the prompt through stdin, pins `--model`, requests a successful JSON terminal result, and uses `--workspace` with `--sandbox enabled`. Read-only adds `--mode ask` and denies `Write(**)` and `Shell(*)`; it cannot run shell-based tests. Writers use the sandbox in their dedicated workspace without `--force` or `--yolo`. + +Each Cursor attempt exclusively creates a private `.cursor-config` directory (0700) containing `cli-config.json` (0600), sets `CURSOR_CONFIG_DIR` for preflight and execution, and removes that directory on completion, failure, timeout, or handled cancellation. Existing paths are never overwritten or removed. The temporary permissions deny `Mcp(*:*)` and `WebFetch(*)`; no user configuration is edited and no credentials are copied. Cursor's authentication storage is separate from this configuration directory. + +Cursor's CLI currently exposes no supported switch to disable recursive subagents, project rules, skills, plugins, or hooks. The runner passes `--trust` for the assigned workspace so headless execution can start; this may authorize project startup hooks. Use only trusted workspaces and keep the parent-owned assignment in the prompt. These settings are not a clean-room guarantee, and the receipt does not prove that project hooks or every descendant were sandboxed. Cursor's native child tools also differ from the parent's tools; MCP-dependent work must stay native. See Cursor's [configuration](https://cursor.com/docs/cli/reference/configuration), [permissions](https://cursor.com/docs/cli/reference/permissions), and [output format](https://cursor.com/docs/cli/reference/output-format) contracts. + Every concurrent external lane needs distinct prompt, output, and receipt paths. The launcher reserves output and receipt paths exclusively and refuses to overwrite them. ## Completion and dropouts @@ -90,10 +100,10 @@ Success requires all of these: 1. Exit status `0`. 2. Receipt status `complete`. -3. Either `modelVerified: true` with `modelEvidence: "provider-report"`, or a Codex receipt with `reportedModel: null`, `modelVerified: false`, and `modelEvidence: "pinned-argv"`. For Claude's `fable` and `opus` aliases, the concrete provider report must belong to the requested family. Codex 0.149.0 accepts the exact `--model` argument but does not report the served model in its JSONL stream. +3. Either `modelVerified: true` with `modelEvidence: "provider-report"`, or a Codex or Cursor receipt with `reportedModel: null`, `modelVerified: false`, and `modelEvidence: "pinned-argv"`. For Claude's `fable` and `opus` aliases, the concrete provider report must belong to the requested family. Codex 0.149.0 and Cursor accept an exact `--model` argument but may omit served-model identity in their result. For pinned-argv evidence, verify the receipt provider, model, effort, and exact `--model` value against the assigned descriptor; Cursor also requires `effort: "default"`. Do not describe pinned arguments as provider-verified identity. 4. A non-empty output file. -The receipt also carries elapsed time, token usage when the CLI exposes it, and cost when available. Keep it with the arena or review artifacts so parent-harness comparisons are evidence-based. +The receipt also carries elapsed time, token usage when the CLI exposes it, and cost when available. Cursor returns session IDs and may return token usage; missing model identity, usage, or cost stays null. Keep it with the arena or review artifacts so parent-harness comparisons are evidence-based. Any missing CLI, failed login, unavailable model, explicit timeout, cancellation, catchable post-reservation launcher failure, non-zero child exit, malformed result, or model mismatch is a receipt-bearing dropout. Record it and apply the calling skill's existing dropout policy. A `cancelled` receipt proves that the runner received the signal; its `signal` field is non-null only when the runner sent that signal to a still-active direct CLI child, and remains null when cancellation only stopped a post-exit pipe drain. The provider CLI owns any processes it starts beneath that direct child; the receipt does not claim a process-tree kill. Do not delete or overwrite the receipt. Never substitute the parent model, retry another provider, or reinterpret an external descriptor as a native model slug. diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts index 4fcce242..ade6bd79 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts @@ -13,7 +13,7 @@ import { UsageError, } from "./types.ts"; -const HELP = `Usage: pstack-runner --parent --provider \\ +const HELP = `Usage: pstack-runner --parent --provider \\ --model --effort --mode \\ --prompt --cwd --output --receipt [--timeout ] @@ -98,7 +98,7 @@ export function parseArgs(argv: readonly string[]): RunnerOptions | null { parent: oneOf("parent", stringValue(parsed.values.parent), PARENTS) as Parent, provider: oneOf("provider", stringValue(parsed.values.provider), PROVIDERS) as Provider, model: required("model", stringValue(parsed.values.model)), - effort: oneOf("effort", stringValue(parsed.values.effort), EFFORTS) as Effort, + effort: oneOf("effort", stringValue(parsed.values.effort), [...EFFORTS, "default"]) as Effort, mode, promptPath: required("prompt", stringValue(parsed.values.prompt)), cwd: required("cwd", stringValue(parsed.values.cwd)), diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts index 5f2b10c6..c9ae8e0e 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts @@ -13,6 +13,8 @@ export interface CommandSpec { export function preflightCommand(provider: Provider): CommandSpec { switch (provider) { + case "cursor": + return { command: "cursor-agent", args: ["status", "--format", "json"], stdin: "none" }; case "claude": return { command: "claude", @@ -65,6 +67,18 @@ function effortOverride(effort: Effort): string { export function invocationCommand(options: RunnerOptions): CommandSpec { switch (options.provider) { + case "cursor": + return { + command: "cursor-agent", + args: [ + "--print", "--output-format", "json", "--trust", + "--model", options.model, + "--workspace", options.cwd, + "--sandbox", "enabled", + ...(options.mode === "read-only" ? ["--mode", "ask"] : []), + ], + stdin: "prompt", + }; case "claude": return { command: "claude", diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts new file mode 100644 index 00000000..c520fde1 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { invocationCommand } from "./commands.ts"; +import { cursorConfigDirectory } from "./cursor.ts"; +import { parseArgs } from "./cli.ts"; +import { runLane, validateOptions } from "./run.ts"; +import type { RunnerOptions } from "./types.ts"; + +let scratch = ""; +let originalPath: string | undefined; +let originalConfig: string | undefined; +const fake = `#!/usr/bin/env bun +import { readFileSync, writeFileSync, statSync } from "node:fs"; +const cwd = process.cwd(); +const fixture = JSON.parse(readFileSync(cwd + "/fixture.json", "utf8")); +const directory = process.env.CURSOR_CONFIG_DIR; +const config = JSON.parse(readFileSync(directory + "/cli-config.json", "utf8")); +const args = process.argv.slice(2); +if (args[0] === "status") { + writeFileSync(cwd + "/observed.json", JSON.stringify({ + config, directory, directoryMode: statSync(directory).mode & 511, + configMode: statSync(directory + "/cli-config.json").mode & 511, + codex: process.env.CODEX_THREAD_ID, claude: process.env.CLAUDECODE, + })); + console.log(JSON.stringify(fixture.auth ?? {isAuthenticated:true})); + process.exit(fixture.authExit ?? 0); +} +const prompt = await Bun.stdin.text(); +writeFileSync(cwd + "/invoked.json", JSON.stringify({args,prompt,cwd})); +if (fixture.delay) await Bun.sleep(fixture.delay); +if (fixture.exit) { console.error(fixture.error); process.exit(fixture.exit); } +console.log(fixture.raw ?? JSON.stringify(fixture.result ?? { + type:"result", subtype:"success", is_error:false, result:"CURSOR_OK", session_id:"cursor-session" +})); +`; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "pstack cursor ")); + const bin = join(scratch, "bin"); + mkdirSync(bin); + const executable = join(bin, "cursor-agent"); + writeFileSync(executable, fake); + chmodSync(executable, 0o755); + originalPath = process.env.PATH; + originalConfig = process.env.CURSOR_CONFIG_DIR; + process.env.PATH = `${bin}:${originalPath}`; + process.env.CURSOR_CONFIG_DIR = join(scratch, "user-config"); + mkdirSync(process.env.CURSOR_CONFIG_DIR); + writeFileSync(join(process.env.CURSOR_CONFIG_DIR, "cli-config.json"), "USER CONFIG"); + writeFileSync(join(scratch, "prompt.md"), "Read file named 'a b'; reply with CURSOR_OK.\n"); + writeFileSync(join(scratch, "fixture.json"), "{}"); +}); +afterEach(() => { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalConfig === undefined) delete process.env.CURSOR_CONFIG_DIR; + else process.env.CURSOR_CONFIG_DIR = originalConfig; + rmSync(scratch, { recursive: true, force: true }); +}); +function options(overrides: Partial = {}): RunnerOptions { + return { + parent: "codex", provider: "cursor", model: "composer-2.5", effort: "default", + mode: "read-only", promptPath: join(scratch, "prompt.md"), cwd: scratch, + outputPath: join(scratch, "result.txt"), receiptPath: join(scratch, "receipt.json"), timeoutMs: null, + ...overrides, + }; +} +function fixture(value: object): void { + writeFileSync(join(scratch, "fixture.json"), JSON.stringify(value)); +} +function observed(name: string): any { + return JSON.parse(readFileSync(join(scratch, name), "utf8")); +} + +describe("Cursor external lanes", () => { + for (const parent of ["claude", "codex"] as const) { + it(`runs from ${parent} with exact model, stdin, and private permissions`, async () => { + const opts = options({ parent }); + const result = await runLane(opts); + expect(result.exitCode).toBe(0); + expect(result.receipt).toMatchObject({status:"complete",provider:"cursor",model:"composer-2.5",effort:"default",modelVerified:false,reportedModel:null,modelEvidence:"pinned-argv",sessionId:"cursor-session"}); + expect(readFileSync(opts.outputPath, "utf8")).toBe("CURSOR_OK"); + expect(observed("invoked.json")).toMatchObject({cwd:realpathSync(scratch),prompt:readFileSync(opts.promptPath, "utf8")}); + expect(observed("invoked.json").args).toEqual(["--print","--output-format","json","--trust","--model","composer-2.5","--workspace",scratch,"--sandbox","enabled","--mode","ask"]); + const config = observed("observed.json"); + expect(config).toMatchObject({directory:cursorConfigDirectory(opts),directoryMode:0o700,configMode:0o600}); + expect(config.config.permissions.deny).toEqual(["Mcp(*:*)","WebFetch(*)","Write(**)","Shell(*)"]); + expect(config.codex).toBeUndefined(); + expect(config.claude).toBeUndefined(); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + expect(readFileSync(join(scratch,"user-config","cli-config.json"),"utf8")).toBe("USER CONFIG"); + }); + } + it("accepts default effort through the command-line parser", () => { + const parsed = parseArgs(["--parent","codex","--provider","cursor","--model","composer-2.5","--effort","default","--mode","read-only","--prompt",join(scratch,"prompt.md"),"--cwd",scratch,"--output",join(scratch,"out"),"--receipt",join(scratch,"receipt")]); + expect(parsed?.effort).toBe("default"); + }); + it("rejects unpinned selection, flag injection, and unsupported effort", () => { + for (const model of ["auto", "Auto", "--force", "composer 2.5", ""]) expect(() => validateOptions(options({ model }))).toThrow(); + expect(() => validateOptions(options({ effort: "high" }))).toThrow("default effort"); + expect(() => validateOptions(options({ provider:"grok", model:"grok-4.6" }))).toThrow("only supported by Cursor"); + }); + it("keeps writers sandboxed without ask mode or blanket force", async () => { + const opts = options({mode:"isolated-write"}); + expect(invocationCommand(opts).args).not.toContain("--force"); + expect(invocationCommand(opts).args).not.toContain("ask"); + expect((await runLane(opts)).exitCode).toBe(0); + expect(observed("observed.json").config.permissions.deny).toEqual(["Mcp(*:*)","WebFetch(*)"]); + }); + for (const auth of [{isAuthenticated:false}, {isAuthenticated:"true"}, {status:"authenticated"}]) { + it(`rejects unconfirmed auth ${JSON.stringify(auth)}`, async () => { + fixture({auth}); + const opts = options(); + expect((await runLane(opts)).receipt.status).toBe("unauthenticated"); + expect(existsSync(join(scratch,"invoked.json"))).toBe(false); + expect(existsSync(opts.outputPath)).toBe(false); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + }); + } + for (const raw of ["not JSON", "null", JSON.stringify({type:"result",subtype:"error",is_error:true,result:"failed"}), JSON.stringify({type:"result",subtype:"success",is_error:false,result:" "})]) { + it(`rejects malformed or unsuccessful result ${raw}`, async () => { + fixture({raw}); + const opts = options(); + expect((await runLane(opts)).receipt.status).toBe("malformed-output"); + expect(existsSync(opts.outputPath)).toBe(false); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + }); + } + it("rejects a contradictory model report", async () => { + fixture({result:{type:"result",subtype:"success",is_error:false,result:"OK",model:"composer-2.5-other"}}); + expect((await runLane(options())).receipt.status).toBe("malformed-output"); + }); + it("records actual Cursor usage without inventing cost or model proof", async () => { + fixture({result:{type:"result",subtype:"success",is_error:false,result:"OK",session_id:"s1",usage:{inputTokens:18779,outputTokens:37,cacheReadTokens:590,cacheWriteTokens:0}}}); + expect((await runLane(options())).receipt).toMatchObject({modelVerified:false,modelEvidence:"pinned-argv",usage:{inputTokens:18779,outputTokens:37,cachedInputTokens:590,cacheCreationInputTokens:0},costUsd:null}); + }); + it("does not overwrite or remove a preexisting config directory", async () => { + const opts = options(); + mkdirSync(cursorConfigDirectory(opts)); + writeFileSync(join(cursorConfigDirectory(opts),"keep"),"keep"); + expect((await runLane(opts)).receipt.status).toBe("child-failed"); + expect(readFileSync(join(cursorConfigDirectory(opts),"keep"),"utf8")).toBe("keep"); + expect(existsSync(opts.outputPath)).toBe(false); + }); + it("does not create configuration when output reservation fails", async () => { + const opts = options(); + writeFileSync(opts.outputPath,"existing"); + await expect(runLane(opts)).rejects.toThrow(); + expect(readFileSync(opts.outputPath,"utf8")).toBe("existing"); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + }); + it("preserves a provider rejection and cleans up", async () => { + fixture({exit:1,error:"Requested model is unavailable"}); + const opts = options(); + expect((await runLane(opts)).receipt.status).toBe("unavailable-model"); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + }); + it("cleans up after an explicit deadline", async () => { + fixture({delay:5000}); + const opts = options({timeoutMs:1000}); + expect((await runLane(opts)).receipt.status).toBe("timed-out"); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + expect(existsSync(opts.outputPath)).toBe(false); + }); +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts new file mode 100644 index 00000000..f39fff02 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts @@ -0,0 +1,26 @@ +import { type AccessMode, type Effort, type RunnerOptions, UsageError } from "./types.ts"; + +export function validateCursorModel(model: string, effort: Effort): void { + if (effort !== "default") throw new UsageError("Cursor requires default effort; select reasoning variants by their exact model slug"); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(model) || model.toLowerCase() === "auto") { + throw new UsageError("Cursor requires an exact model slug from cursor-agent models, not auto"); + } +} + +export function cursorConfigDirectory(options: RunnerOptions): string { + return `${options.receiptPath}.cursor-config`; +} + +export function cursorConfig(mode: AccessMode): object { + return { + version: 1, + editor: { vimMode: false }, + permissions: { + allow: [], + deny: [ + "Mcp(*:*)", "WebFetch(*)", + ...(mode === "read-only" ? ["Write(**)", "Shell(*)"] : []), + ], + }, + }; +} diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts index 81ed53d4..289475c8 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts @@ -157,6 +157,34 @@ function parseCodex(stdout: string): ParsedOutput { }; } +function parseCursor(stdout: string): ParsedOutput { + let raw: unknown; + try { + raw = JSON.parse(stdout); + } catch { + throw new Error("cursor did not emit valid JSON"); + } + const value = object(raw); + if (value?.type !== "result" || value.subtype !== "success" || value.is_error !== false) { + throw new Error("cursor did not report a successful result"); + } + const text = nullableString(value.result); + if (text === null || text.trim().length === 0) throw new Error("cursor result did not contain final text"); + const usage = object(value.usage); + return { + text, + reportedModel: nullableString(value.model), + sessionId: nullableString(value.session_id), + usage: normalizedUsage(usage === null ? null : { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cached_input_tokens: usage.cacheReadTokens, + cache_creation_input_tokens: usage.cacheWriteTokens, + }), + costUsd: finiteNumber(value.total_cost_usd) ?? null, + }; +} + export function parseProviderOutput( provider: Provider, stdout: string, @@ -164,6 +192,8 @@ export function parseProviderOutput( requestedModel: string ): ParsedOutput { switch (provider) { + case "cursor": + return parseCursor(stdout); case "claude": return parseClaude(stdout, requestedModel); case "codex": @@ -179,6 +209,7 @@ export function reportedModelMatches( reported: string | null ): boolean { if (reported === null) return false; + if (provider === "cursor") return reported === requested; if (provider === "claude" && isRollingClaudeAlias(requested)) { return concreteModelMatchesRollingAlias(requested, reported); } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index 054564a4..2bdfbc35 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -4,12 +4,14 @@ import { mkdirSync, openSync, readFileSync, + rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { dirname, resolve } from "node:path"; import { invocationCommand, preflightCommand, type CommandSpec } from "./commands.ts"; +import { cursorConfigDirectory, cursorConfig, validateCursorModel } from "./cursor.ts"; import { versionedClaudeAlias } from "./model-aliases.ts"; import { parseProviderOutput, reportedModelMatches } from "./parse-output.ts"; import type { @@ -354,6 +356,15 @@ function preflightPassed(provider: Provider, model: string, result: ProcessResul if (result.exitCode !== 0 || result.timedOut) return false; const combined = `${result.stdout}\n${result.stderr}`; switch (provider) { + case "cursor": { + try { + const value: unknown = JSON.parse(result.stdout); + return value !== null && typeof value === "object" && + (value as { isAuthenticated?: unknown }).isAuthenticated === true; + } catch { + return false; + } + } case "claude": { try { const value: unknown = JSON.parse(result.stdout); @@ -450,7 +461,7 @@ function modelProof( modelEvidence: "provider-report", }; } - if (provider === "codex" && reported === null) { + if ((provider === "codex" || provider === "cursor") && reported === null) { return { reportedModel: null, modelVerified: false, @@ -483,6 +494,8 @@ function completeReceipt( } export function validateOptions(options: RunnerOptions): void { + if (options.provider === "cursor") validateCursorModel(options.model, options.effort); + else if (options.effort === "default") throw new UsageError("default effort is only supported by Cursor"); if (options.parent === options.provider) { throw new UsageError( `provider ${options.provider} is native to parent ${options.parent}; use the parent subagent primitive` @@ -535,6 +548,7 @@ async function executeLane( const startedAt = new Date(started).toISOString(); const prompt = readFileSync(options.promptPath, "utf8"); const env = childEnvironment(options.provider); + if (options.provider === "cursor") env.CURSOR_CONFIG_DIR = cursorConfigDirectory(options); const executable = Bun.which(invocation.command, { PATH: env.PATH, cwd: options.cwd, @@ -866,9 +880,16 @@ export async function runLane( argv: [invocation.command, ...invocation.args], }; const cancellation = installRunCancellation(); + let createdCursorConfig = false; try { reserveOutputs(options); try { + if (options.provider === "cursor") { + const directory = cursorConfigDirectory(options); + mkdirSync(directory, { mode: 0o700 }); + createdCursorConfig = true; + writeFileSync(`${directory}/cli-config.json`, JSON.stringify(cursorConfig(options.mode)), { flag: "wx", mode: 0o600 }); + } return await executeLane( options, cancellation, @@ -920,7 +941,11 @@ export async function runLane( return { exitCode: statusExitCode(status), receipt }; } } finally { - cancellation.dispose(); + try { + if (createdCursorConfig) rmSync(cursorConfigDirectory(options), { recursive: true, force: true }); + } finally { + cancellation.dispose(); + } } } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts index 11c6dfb9..24cb95dd 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts @@ -1,11 +1,11 @@ export const PARENTS = ["claude", "codex"] as const; -export const PROVIDERS = ["claude", "codex", "grok"] as const; +export const PROVIDERS = ["claude", "codex", "grok", "cursor"] as const; export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; export const ACCESS_MODES = ["read-only", "isolated-write"] as const; export type Parent = (typeof PARENTS)[number]; export type Provider = (typeof PROVIDERS)[number]; -export type Effort = (typeof EFFORTS)[number]; +export type Effort = (typeof EFFORTS)[number] | "default"; export type AccessMode = (typeof ACCESS_MODES)[number]; export interface RunnerOptions { diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index 9a0e7441..ec73b85f 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -33,11 +33,17 @@ Read the current parent-specific sheet when it exists. Before matrix validation, Treat the normalized values as current role-to-family assignments. Overlay those rows on the complete first-run role map in step 7. Materialize any missing documented role row from that map on the next successful write. A duplicate or unknown role row is inconsistent state; report it and resolve it before probing. A bare host-native slug from an older sheet is also invalid because it does not say which provider owns it. A versioned Claude model outside the two migration families remains inconsistent state. If the sheet is missing, use the complete first-run role map and the model matrix's Default effort cells. +### Optional Cursor roles + +Cursor lanes are opt-in and separate from the four baseline families below. Preserve existing `cursor:@default` assignments and collect any named Cursor role additions before probing; do not add Cursor to the default panel. Discover exact slugs with `cursor-agent models`; require `default` effort and reject Cursor's `auto` model selector. In step 5, each distinct selected Cursor slug gets one external read-only runner probe from the current parent, in addition to the baseline probes. `cursor-agent status --format json` must confirm authentication, but only a successful model result establishes availability. The CLI must be available as `cursor-agent`; never substitute an unrelated `agent` binary. + +A selected Cursor lane that fails its probe blocks that proposed configuration without changing the active sheet. An unassigned Cursor provider requires neither installation nor probing. Model parsing and final role validation must accept these discovered, probed optional descriptors alongside the baseline matrix families and aliases. Store them directly in the same role map with `@default`; do not ask a separate reasoning-effort question or create another source of truth. + ### 3. Parse per-family efforts -Read the model matrix. Every non-alias value must match `:@`. Map it to exactly one matrix family by `(provider, model)`, require its effort to appear in that row's Selectable efforts cell, and collect the effort. `inherit-parent` and `auto` rows carry no family effort. +Read the model matrix. Every non-alias value must match `:@`. Apply the optional Cursor rules above to `cursor:*` values. For baseline descriptors, map it to exactly one matrix family by `(provider, model)`, require its effort to appear in that row's Selectable efforts cell, and collect the effort. `inherit-parent` and `auto` rows carry no family effort. -An unmatched provider/model, out-of-domain effort, duplicate role, or unknown role is inconsistent state. Stop, show the conflicting rows verbatim, and ask for an explicit matrix family or alias replacement. If one or more families have mixed efforts, show every conflicting family and role row, then ask for one normalized effort per family from its Selectable efforts cell. Do not invent a precedence rule. Do not probe or write while any inconsistency is unresolved. +An unmatched provider/model outside the optional Cursor contract, out-of-domain effort, duplicate role, or unknown role is inconsistent state. Stop, show the conflicting rows verbatim, and ask for an explicit matrix family or alias replacement. If one or more families have mixed efforts, show every conflicting family and role row, then ask for one normalized effort per family from its Selectable efforts cell. Do not invent a precedence rule. Do not probe or write while any inconsistency is unresolved. One distinct effort per family is the current value. A family with no non-alias occurrence is unassigned; use its matrix Default effort as the proposed value and label it unassigned rather than calling it current. @@ -45,9 +51,9 @@ One distinct effort per family is the current value. A family with no non-alias Ask exactly four effort questions, one each for Fable, Sol, Grok, and Opus. Name each model, its current or proposed value, and the Selectable efforts from its matrix row. Empty input keeps a current value or accepts the matrix proposal for an unassigned family. On a first run, state the four matrix defaults before asking. On a rerun, state the four parsed values without offering to reset customized role lanes. -### 5. Probe the four requested pairs +### 5. Probe the requested pairs -Probe only the four selected `provider:model@effort` pairs. Run one probe per family, even when two families share a provider. Do not enumerate or offer older models as substitutes. A failed probe writes nothing: report the failing pair and provider, stop, and keep the active sheet plus parent integration bytes unchanged. A failed first run creates neither artifact. +Probe the four selected baseline `provider:model@effort` pairs and any assigned optional Cursor pairs. Run one probe per family, even when two families share a provider. Do not enumerate or offer older models as substitutes. A failed probe writes nothing: report the failing pair and provider, stop, and keep the active sheet plus parent integration bytes unchanged. A failed first run creates neither artifact. | Family | Pair source | Claude parent route | Codex parent route | Availability proof | |---|---|---|---|---| @@ -56,7 +62,7 @@ Probe only the four selected `provider:model@effort` pairs. Run one probe per fa | Grok | Grok matrix row + selected effort | Grok CLI | Grok CLI | `grok models` must list the requested model; one-turn probe | | Opus | Opus matrix row + selected effort | native Agent `pstack-opus-` | Claude CLI | native one-turn probe or `claude auth status --json` plus one-turn probe | -Use a tiny read-only probe that returns a unique marker. A login-status command alone proves credentials, not that the requested model and effort flags run. Record native and external results separately. Never call the external launcher for the parent's own provider. On a Claude parent, the Fable and Opus probes are one-turn runs of the mapped `pstack--` agent. On a Codex parent, the Sol probe is native `spawn_agent` with the selected `reasoning_effort`. Every other pair uses the external runner with the selected effort flag. +Use a tiny read-only probe that returns a unique marker. A login-status command alone proves credentials, not that the requested model and effort flags run. Record native and external results separately. Never call the external launcher for the parent's own provider. On a Claude parent, the Fable and Opus probes are one-turn runs of the mapped `pstack--` agent. On a Codex parent, the Sol probe is native `spawn_agent` with the selected `reasoning_effort`. Every other pair uses the external runner with the selected effort; Cursor uses `default` and passes no reasoning-effort flag to its CLI. Receipts and native transcripts prove the requested effort and the route. They do not prove a provider's hidden applied reasoning depth. There is no implicit timeout, weaker-model fallback, same-provider external fallback, or second mutable configuration source. @@ -67,11 +73,11 @@ Build the new sheet in memory. Do not write it yet. - First run: start from the complete role assignments in step 7. - Rerun: start from the normalized complete role map from step 2, preserving each loaded row's lane order and family (or alias) per lane. -After effort selection, ask whether to keep those role-to-family assignments or change named roles. Keeping them is the default. Apply only role changes the operator names; never offer a reset of a customized sheet to the first-run assignments. A changed role may use one of the four probed matrix families, `inherit-parent`, or `auto`. +After effort selection, ask whether to keep those role-to-family assignments or change named roles. Keeping them is the default. Apply only role changes the operator names; never offer a reset of a customized sheet to the first-run assignments. A changed role may use one of the four probed matrix families, a probed optional Cursor descriptor, `inherit-parent`, or `auto`. Require the final role map to contain at least one descriptor from each matrix family. The sheet stores effort only in role descriptors, so an unassigned family's selection cannot persist without adding a second source of truth. -Rewrite every matrix-family descriptor to `provider:model@`. Leave `inherit-parent` and `auto` unchanged. An effort-only rerun cannot change a role's family. Changing Grok's effort updates every Grok occurrence and does not move a Sol role onto Grok. Refuse an unqualified slug, an unavailable route, a model other than the four matrix families, or a provider/model mismatch. +Rewrite every matrix-family descriptor to `provider:model@`. Leave `inherit-parent` and `auto` unchanged. An effort-only rerun cannot change a role's family. Changing Grok's effort updates every Grok occurrence and does not move a Sol role onto Grok. Refuse an unqualified slug, an unavailable route, a model outside the four matrix families and probed optional Cursor descriptors, or a provider/model mismatch. ### 7. Confirm and commit @@ -109,12 +115,12 @@ interrogate reviewers: claude:fable@max, codex:gpt-5.6-sol@max, grok:grok-4.6@xh Render the parent integration in memory before either write. On Claude, the integration is the single `@~/.claude/pstack-models.md` include in `~/.claude/CLAUDE.md`. On Codex, it is the exact sheet bytes between one `` and `` pair in `~/.codex/AGENTS.md`. Replace that whole bounded block on a rerun. Insert one block at the end on first run. If either marker is missing, duplicated, or reversed, stop and report inconsistent state instead of guessing a boundary. -Snapshot every target's current bytes. Write the sheet and parent integration only after all four probes pass and the operator confirms. Read both targets back and compare them with the in-memory render. If either write or readback fails, restore every snapshot and report the failure. An unchanged rerun must produce byte-identical sheet and integration content after normalization. +Snapshot every target's current bytes. Write the sheet and parent integration only after all four baseline probes and every assigned optional Cursor probe pass and the operator confirms. Read both targets back and compare them with the in-memory render. If either write or readback fails, restore every snapshot and report the failure. An unchanged rerun must produce byte-identical sheet and integration content after normalization. Do not copy the model sheet between harnesses without rerunning the parent-specific probes; route availability can differ even on the same host. ### 9. Behavioral smoke -Before declaring setup complete, run one small read-only mixed panel from this parent: all four chosen descriptors, distinct output/receipt paths, and an independent cross-judge. Launch Claude-native agents and every external process in the background with retained handles, then drain them. Verify the native transcript entries and every external receipt. A structural config check or unit test is not a substitute. +Before declaring setup complete, run one small read-only mixed panel from this parent: all four chosen baseline descriptors plus every assigned optional Cursor descriptor, distinct output/receipt paths, and an independent cross-judge. Launch Claude-native agents and every external process in the background with retained handles, then drain them. Verify the native transcript entries and every external receipt. A structural config check or unit test is not a substitute. Report the sheet path, parent route table, requested-effort probe results, smoke results, and external elapsed/token/cost receipts. Re-running this skill re-probes and updates the same sheet. Do not claim the provider exposed hidden applied-effort observability. From 4f0c60fb3013fc874972b79de59ef42ab4ba16cf Mon Sep 17 00:00:00 2001 From: Arjit Jaiswal Date: Wed, 16 Sep 2026 01:14:41 -0400 Subject: [PATCH 2/3] Update setup section expectation for optional providers --- .../skills/poteto-mode/scripts/runner/model-matrix.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/model-matrix.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/model-matrix.test.ts index e0d6df1e..f8db1dbf 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/model-matrix.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/model-matrix.test.ts @@ -52,7 +52,7 @@ const SETUP_SECTION_ORDER = [ "### 2. Load current state", "### 3. Parse per-family efforts", "### 4. Collect one requested effort per family", - "### 5. Probe the four requested pairs", + "### 5. Probe the requested pairs", "### 6. Render, preserving role families", "### 7. Confirm and commit", ] as const; From 24c489167b1fb7359525ec200fd8129563e723e8 Mon Sep 17 00:00:00 2001 From: Arjit Jaiswal Date: Wed, 16 Sep 2026 02:02:43 -0400 Subject: [PATCH 3/3] Preserve Cursor API-key authentication and proxy transport --- CHANGES.md | 2 +- .../references/provider-dispatch.md | 4 +- .../poteto-mode/scripts/runner/commands.ts | 7 +- .../poteto-mode/scripts/runner/cursor.test.ts | 80 +++++++++++++++++-- .../poteto-mode/scripts/runner/cursor.ts | 45 ++++++++++- .../skills/poteto-mode/scripts/runner/run.ts | 21 ++--- plugins/pstack/skills/setup-pstack/SKILL.md | 2 +- 7 files changed, 140 insertions(+), 21 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 5cfc5b10..90874e3a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -393,4 +393,4 @@ If you want a clean re-port (e.g. when upstream releases v0.2.0), the rebuild re - babysit: independently authored; workflow informed by Cursor's public `/babysit` behavior — no code or prose copied. - Inspected for prior-art decisions: [v1truv1us/ai-eng-system](https://github.com/v1truv1us/ai-eng-system) (namespaces pstack under `pstack/` but keeps Cursor refs intact); [Evan-Kim2028/agent-fleet](https://github.com/Evan-Kim2028/agent-fleet) (vendors pstack under `base-kit/pstack/`, same posture). -- Add opt-in Cursor external workers through `cursor-agent`, exact model slugs and `@default`, private per-run permissions, JSON receipts, and setup probes. Parent completion accepts explicitly pinned Cursor arguments without claiming served-model verification. Cursor project hooks/rules and recursive subagents remain provider limitations. +- Add opt-in Cursor external workers through `cursor-agent`, exact model slugs and `@default`, private per-run permissions, JSON receipts, and setup probes. API-key authentication is checked by model execution; private configuration preserves the global HTTP/1 transport option without copying other user settings. Parent completion accepts explicitly pinned Cursor arguments without claiming served-model verification. Cursor project hooks/rules and recursive subagents remain provider limitations. diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index b36ec5e7..11f9f999 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -86,9 +86,9 @@ The runner and its preflight have no implicit timeout. Do not invent a duration Read-only mode maps to Claude plan mode with project-only settings and an explicit tool list, Codex's read-only sandbox, and Grok plan mode plus its `read-only` sandbox and read-oriented tool list. Grok's built-in read-only profile deliberately keeps its own state and system temporary directories writable, so point a read-only Grok lane at the actual checkout rather than a worktree under `/tmp`, `/var/tmp`, or the host's temporary directory. `isolated-write` maps to Claude `acceptEdits` with project-only settings, Codex `workspace-write`, and Grok `acceptEdits` plus its `workspace` sandbox and write-capable tool list. Give every writer only a dedicated worktree or output directory. Never route a writer into the primary checkout. -Cursor uses the explicit `cursor-agent` executable, not `agent` (which can name another CLI). Preflight requires `cursor-agent status --format json` to return `isAuthenticated: true`; setup also performs a real model probe because login alone does not prove model access. The runner sends the prompt through stdin, pins `--model`, requests a successful JSON terminal result, and uses `--workspace` with `--sandbox enabled`. Read-only adds `--mode ask` and denies `Write(**)` and `Shell(*)`; it cannot run shell-based tests. Writers use the sandbox in their dedicated workspace without `--force` or `--yolo`. +Cursor uses the explicit `cursor-agent` executable, not `agent` (which can name another CLI). For stored OAuth credentials, preflight requires `cursor-agent status --format json` to return `isAuthenticated: true`. With a nonblank `CURSOR_API_KEY`, preflight checks `cursor-agent --version` only and records that authentication is deferred to the real model invocation; Cursor status does not report API-key authentication. The key stays in the environment. Authentication failures from execution fail the lane. Setup always performs a real model probe because preflight alone does not prove model access. The runner sends the prompt through stdin, pins `--model`, requests a successful JSON terminal result, and uses `--workspace` with `--sandbox enabled`. Read-only adds `--mode ask` and denies `Write(**)` and `Shell(*)`; it cannot run shell-based tests. Writers use the sandbox in their dedicated workspace without `--force` or `--yolo`. -Each Cursor attempt exclusively creates a private `.cursor-config` directory (0700) containing `cli-config.json` (0600), sets `CURSOR_CONFIG_DIR` for preflight and execution, and removes that directory on completion, failure, timeout, or handled cancellation. Existing paths are never overwritten or removed. The temporary permissions deny `Mcp(*:*)` and `WebFetch(*)`; no user configuration is edited and no credentials are copied. Cursor's authentication storage is separate from this configuration directory. +Each Cursor attempt exclusively creates a private `.cursor-config` directory (0700) containing `cli-config.json` (0600), sets `CURSOR_CONFIG_DIR` for preflight and execution, and removes that directory on completion, failure, timeout, or handled cancellation. Existing paths are never overwritten or removed. The temporary permissions deny `Mcp(*:*)` and `WebFetch(*)`; no user configuration is edited and no credentials are copied. Cursor's authentication storage is separate from this configuration directory. Before overriding the directory, the runner reads the original global configuration (`CURSOR_CONFIG_DIR`, otherwise `XDG_CONFIG_HOME/cursor`, otherwise `~/.cursor`) and preserves only a boolean `network.useHttp1ForAgent`, needed by some proxies. It never copies permissions, hooks, credentials, endpoints, or other settings. An unreadable or malformed existing configuration fails the lane instead of silently losing its transport setting. Cursor's CLI currently exposes no supported switch to disable recursive subagents, project rules, skills, plugins, or hooks. The runner passes `--trust` for the assigned workspace so headless execution can start; this may authorize project startup hooks. Use only trusted workspaces and keep the parent-owned assignment in the prompt. These settings are not a clean-room guarantee, and the receipt does not prove that project hooks or every descendant were sandboxed. Cursor's native child tools also differ from the parent's tools; MCP-dependent work must stay native. See Cursor's [configuration](https://cursor.com/docs/cli/reference/configuration), [permissions](https://cursor.com/docs/cli/reference/permissions), and [output format](https://cursor.com/docs/cli/reference/output-format) contracts. diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts index c9ae8e0e..b6715a36 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts @@ -1,3 +1,4 @@ +import { cursorHasApiKey } from "./cursor.ts"; import type { AccessMode, Effort, @@ -14,7 +15,11 @@ export interface CommandSpec { export function preflightCommand(provider: Provider): CommandSpec { switch (provider) { case "cursor": - return { command: "cursor-agent", args: ["status", "--format", "json"], stdin: "none" }; + return { + command: "cursor-agent", + args: cursorHasApiKey() ? ["--version"] : ["status", "--format", "json"], + stdin: "none", + }; case "claude": return { command: "claude", diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts index c520fde1..7c58263a 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.test.ts @@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy import { tmpdir } from "node:os"; import { join } from "node:path"; import { invocationCommand } from "./commands.ts"; -import { cursorConfigDirectory } from "./cursor.ts"; +import { cursorConfig, cursorUserConfigPath, cursorConfigDirectory } from "./cursor.ts"; import { parseArgs } from "./cli.ts"; import { runLane, validateOptions } from "./run.ts"; import type { RunnerOptions } from "./types.ts"; @@ -11,6 +11,7 @@ import type { RunnerOptions } from "./types.ts"; let scratch = ""; let originalPath: string | undefined; let originalConfig: string | undefined; +let originalApiKey: string | undefined; const fake = `#!/usr/bin/env bun import { readFileSync, writeFileSync, statSync } from "node:fs"; const cwd = process.cwd(); @@ -18,17 +19,18 @@ const fixture = JSON.parse(readFileSync(cwd + "/fixture.json", "utf8")); const directory = process.env.CURSOR_CONFIG_DIR; const config = JSON.parse(readFileSync(directory + "/cli-config.json", "utf8")); const args = process.argv.slice(2); -if (args[0] === "status") { +if (args[0] === "status" || args[0] === "--version") { writeFileSync(cwd + "/observed.json", JSON.stringify({ - config, directory, directoryMode: statSync(directory).mode & 511, + args, config, directory, directoryMode: statSync(directory).mode & 511, configMode: statSync(directory + "/cli-config.json").mode & 511, codex: process.env.CODEX_THREAD_ID, claude: process.env.CLAUDECODE, })); + if (args[0] === "--version") { console.log("2026.09.10-fixture"); process.exit(fixture.versionExit ?? 0); } console.log(JSON.stringify(fixture.auth ?? {isAuthenticated:true})); process.exit(fixture.authExit ?? 0); } const prompt = await Bun.stdin.text(); -writeFileSync(cwd + "/invoked.json", JSON.stringify({args,prompt,cwd})); +writeFileSync(cwd + "/invoked.json", JSON.stringify({args,prompt,cwd,hasApiKey:!!process.env.CURSOR_API_KEY})); if (fixture.delay) await Bun.sleep(fixture.delay); if (fixture.exit) { console.error(fixture.error); process.exit(fixture.exit); } console.log(fixture.raw ?? JSON.stringify(fixture.result ?? { @@ -45,10 +47,12 @@ beforeEach(() => { chmodSync(executable, 0o755); originalPath = process.env.PATH; originalConfig = process.env.CURSOR_CONFIG_DIR; + originalApiKey = process.env.CURSOR_API_KEY; + delete process.env.CURSOR_API_KEY; process.env.PATH = `${bin}:${originalPath}`; process.env.CURSOR_CONFIG_DIR = join(scratch, "user-config"); mkdirSync(process.env.CURSOR_CONFIG_DIR); - writeFileSync(join(process.env.CURSOR_CONFIG_DIR, "cli-config.json"), "USER CONFIG"); + writeFileSync(join(process.env.CURSOR_CONFIG_DIR, "cli-config.json"), "{}"); writeFileSync(join(scratch, "prompt.md"), "Read file named 'a b'; reply with CURSOR_OK.\n"); writeFileSync(join(scratch, "fixture.json"), "{}"); }); @@ -57,6 +61,8 @@ afterEach(() => { else process.env.PATH = originalPath; if (originalConfig === undefined) delete process.env.CURSOR_CONFIG_DIR; else process.env.CURSOR_CONFIG_DIR = originalConfig; + if (originalApiKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = originalApiKey; rmSync(scratch, { recursive: true, force: true }); }); function options(overrides: Partial = {}): RunnerOptions { @@ -90,9 +96,71 @@ describe("Cursor external lanes", () => { expect(config.codex).toBeUndefined(); expect(config.claude).toBeUndefined(); expect(existsSync(cursorConfigDirectory(opts))).toBe(false); - expect(readFileSync(join(scratch,"user-config","cli-config.json"),"utf8")).toBe("USER CONFIG"); + expect(readFileSync(join(scratch,"user-config","cli-config.json"),"utf8")).toBe("{}"); }); } + it("defers API-key authentication to the actual model invocation", async () => { + process.env.CURSOR_API_KEY = "fixture-secret-key"; + fixture({auth:{isAuthenticated:false}}); + const result = await runLane(options()); + expect(result.receipt.status).toBe("complete"); + expect(observed("observed.json").args).toEqual(["--version"]); + expect(observed("invoked.json").hasApiKey).toBe(true); + expect(JSON.stringify(result.receipt)).toContain("authentication deferred"); + for (const value of [result.receipt, observed("observed.json"), observed("invoked.json")]) { + expect(JSON.stringify(value)).not.toContain("fixture-secret-key"); + } + }); + it("does not skip a failed executable preflight with an API key", async () => { + process.env.CURSOR_API_KEY = "fixture-key"; + fixture({versionExit:1}); + expect((await runLane(options())).receipt.status).not.toBe("complete"); + expect(existsSync(join(scratch,"invoked.json"))).toBe(false); + }); + it("keeps blank API keys on the strict OAuth preflight", async () => { + process.env.CURSOR_API_KEY = " "; + fixture({auth:{isAuthenticated:false}}); + expect((await runLane(options())).receipt.status).toBe("unauthenticated"); + expect(observed("observed.json").args).toEqual(["status","--format","json"]); + }); + for (const error of ["HTTP 401", "Unauthorized", "Invalid API key", "API key has expired"]) { + it(`classifies model authentication rejection: ${error}`, async () => { + process.env.CURSOR_API_KEY = "fixture-key"; + fixture({exit:1,error}); + const opts = options(); + expect((await runLane(opts)).receipt.status).toBe("unauthenticated"); + expect(existsSync(join(scratch,"invoked.json"))).toBe(true); + expect(existsSync(opts.outputPath)).toBe(false); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + }); + } + for (const useHttp1ForAgent of [true, false, "true"]) { + it(`preserves only a boolean transport option: ${JSON.stringify(useHttp1ForAgent)}`, async () => { + const path = join(scratch,"user-config","cli-config.json"); + const original = JSON.stringify({network:{useHttp1ForAgent,endpoint:"unsafe"},permissions:{allow:["Shell(*)"]},hooks:{run:"unsafe"},apiKey:"secret-sentinel"}); + writeFileSync(path,original); + expect((await runLane(options())).receipt.status).toBe("complete"); + const config = observed("observed.json").config; + expect(config.network).toEqual(typeof useHttp1ForAgent === "boolean" ? {useHttp1ForAgent} : undefined); + expect(config.permissions.allow).toEqual([]); + expect(config.hooks).toBeUndefined(); + expect(JSON.stringify(config)).not.toContain("secret-sentinel"); + expect(readFileSync(path,"utf8")).toBe(original); + }); + } + it("resolves source configuration before creating the private override", () => { + expect(cursorUserConfigPath({CURSOR_CONFIG_DIR:"relative config",XDG_CONFIG_HOME:"/xdg"},"/user","/workspace")).toBe("/workspace/relative config/cli-config.json"); + expect(cursorUserConfigPath({XDG_CONFIG_HOME:"/xdg"},"/user","/workspace")).toBe("/xdg/cursor/cli-config.json"); + expect(cursorUserConfigPath({},"/user","/workspace")).toBe("/user/.cursor/cli-config.json"); + expect(cursorConfig("read-only",join(scratch,"missing.json"))).not.toHaveProperty("network"); + }); + it("fails safely on malformed source config without launching or leaving private files", async () => { + writeFileSync(join(scratch,"user-config","cli-config.json"),"not JSON"); + const opts = options(); + expect((await runLane(opts)).receipt.status).toBe("child-failed"); + expect(existsSync(join(scratch,"observed.json"))).toBe(false); + expect(existsSync(cursorConfigDirectory(opts))).toBe(false); + }); it("accepts default effort through the command-line parser", () => { const parsed = parseArgs(["--parent","codex","--provider","cursor","--model","composer-2.5","--effort","default","--mode","read-only","--prompt",join(scratch,"prompt.md"),"--cwd",scratch,"--output",join(scratch,"out"),"--receipt",join(scratch,"receipt")]); expect(parsed?.effort).toBe("default"); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts index f39fff02..b70766d3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cursor.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; import { type AccessMode, type Effort, type RunnerOptions, UsageError } from "./types.ts"; export function validateCursorModel(model: string, effort: Effort): void { @@ -11,9 +14,11 @@ export function cursorConfigDirectory(options: RunnerOptions): string { return `${options.receiptPath}.cursor-config`; } -export function cursorConfig(mode: AccessMode): object { +export function cursorConfig(mode: AccessMode, configPath: string = cursorUserConfigPath()): object { + const network = cursorTransport(configPath); return { version: 1, + ...(network === undefined ? {} : { network }), editor: { vimMode: false }, permissions: { allow: [], @@ -24,3 +29,41 @@ export function cursorConfig(mode: AccessMode): object { }, }; } + +export function cursorHasApiKey(env: NodeJS.ProcessEnv = process.env): boolean { + return (env.CURSOR_API_KEY?.trim().length ?? 0) > 0; +} + +export function cursorUserConfigPath( + env: NodeJS.ProcessEnv = process.env, + userHome: string = homedir(), + cwd: string = process.cwd() +): string { + const directory = env.CURSOR_CONFIG_DIR?.trim() + ? env.CURSOR_CONFIG_DIR + : env.XDG_CONFIG_HOME?.trim() + ? join(env.XDG_CONFIG_HOME, "cursor") + : join(userHome, ".cursor"); + return resolve(cwd, directory, "cli-config.json"); +} + +function cursorTransport(configPath: string): { useHttp1ForAgent: boolean } | undefined { + let contents: string; + try { + contents = readFileSync(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + let config: unknown; + try { + config = JSON.parse(contents); + } catch { + throw new UsageError(`Cursor global configuration is not valid JSON: ${configPath}`); + } + if (config === null || typeof config !== "object" || Array.isArray(config)) return undefined; + const network = (config as { network?: unknown }).network; + if (network === null || typeof network !== "object" || Array.isArray(network)) return undefined; + const useHttp1ForAgent = (network as { useHttp1ForAgent?: unknown }).useHttp1ForAgent; + return typeof useHttp1ForAgent === "boolean" ? { useHttp1ForAgent } : undefined; +} diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index 2bdfbc35..a86a1850 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -11,7 +11,7 @@ import { } from "node:fs"; import { dirname, resolve } from "node:path"; import { invocationCommand, preflightCommand, type CommandSpec } from "./commands.ts"; -import { cursorConfigDirectory, cursorConfig, validateCursorModel } from "./cursor.ts"; +import { cursorConfigDirectory, cursorConfig, cursorHasApiKey, cursorUserConfigPath, validateCursorModel } from "./cursor.ts"; import { versionedClaudeAlias } from "./model-aliases.ts"; import { parseProviderOutput, reportedModelMatches } from "./parse-output.ts"; import type { @@ -352,11 +352,12 @@ async function waitForGrokPreflightRetry( } } -function preflightPassed(provider: Provider, model: string, result: ProcessResult): boolean { +function preflightPassed(provider: Provider, model: string, result: ProcessResult, apiKeyAuth: boolean = false): boolean { if (result.exitCode !== 0 || result.timedOut) return false; const combined = `${result.stdout}\n${result.stderr}`; switch (provider) { case "cursor": { + if (apiKeyAuth) return true; try { const value: unknown = JSON.parse(result.stdout); return value !== null && typeof value === "object" && @@ -384,14 +385,15 @@ function preflightPassed(provider: Provider, model: string, result: ProcessResul } } -function successfulPreflightEvidence(provider: Provider, model: string): string { +function successfulPreflightEvidence(provider: Provider, model: string, apiKeyAuth: boolean = false): string { + if (apiKeyAuth) return "CURSOR_API_KEY supplied; authentication deferred to model execution"; return provider === "grok" ? `authenticated; model ${model} available` : "authenticated"; } function unavailableStatus(value: string): ReceiptStatus { - if (/not logged in|unauthenticated|authentication|sign in|login required/i.test(value)) { + if (/not logged in|unauthenticated|authentication|sign in|login required|\b401\b|\bunauthori[sz]ed\b|invalid.{0,12}api.?key|api.?key.{0,20}(invalid|expired)/i.test(value)) { return "unauthenticated"; } if (/model.{0,40}(not found|unknown|unavailable|unsupported|not supported|invalid)|invalid.{0,20}model/i.test(value)) { @@ -548,6 +550,7 @@ async function executeLane( const startedAt = new Date(started).toISOString(); const prompt = readFileSync(options.promptPath, "utf8"); const env = childEnvironment(options.provider); + const apiKeyAuth = options.provider === "cursor" && cursorHasApiKey(env); if (options.provider === "cursor") env.CURSOR_CONFIG_DIR = cursorConfigDirectory(options); const executable = Bun.which(invocation.command, { PATH: env.PATH, @@ -642,9 +645,9 @@ async function executeLane( cancellation ); let rawPreflightEvidence = evidence(`${preflightResult.stdout}\n${preflightResult.stderr}`); - let passed = preflightPassed(options.provider, options.model, preflightResult); + let passed = preflightPassed(options.provider, options.model, preflightResult, apiKeyAuth); let preflightEvidence = passed - ? successfulPreflightEvidence(options.provider, options.model) + ? successfulPreflightEvidence(options.provider, options.model, apiKeyAuth) : rawPreflightEvidence; if ( @@ -686,11 +689,11 @@ async function executeLane( cancellation ); rawPreflightEvidence = evidence(`${preflightResult.stdout}\n${preflightResult.stderr}`); - passed = preflightPassed(options.provider, options.model, preflightResult); + passed = preflightPassed(options.provider, options.model, preflightResult, apiKeyAuth); preflightEvidence = retriedPreflightEvidence( firstPreflightEvidence, passed - ? successfulPreflightEvidence(options.provider, options.model) + ? successfulPreflightEvidence(options.provider, options.model, apiKeyAuth) : rawPreflightEvidence, passed ); @@ -888,7 +891,7 @@ export async function runLane( const directory = cursorConfigDirectory(options); mkdirSync(directory, { mode: 0o700 }); createdCursorConfig = true; - writeFileSync(`${directory}/cli-config.json`, JSON.stringify(cursorConfig(options.mode)), { flag: "wx", mode: 0o600 }); + writeFileSync(`${directory}/cli-config.json`, JSON.stringify(cursorConfig(options.mode, cursorUserConfigPath(process.env, undefined, options.cwd))), { flag: "wx", mode: 0o600 }); } return await executeLane( options, diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index ec73b85f..6dd11595 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -35,7 +35,7 @@ Treat the normalized values as current role-to-family assignments. Overlay those ### Optional Cursor roles -Cursor lanes are opt-in and separate from the four baseline families below. Preserve existing `cursor:@default` assignments and collect any named Cursor role additions before probing; do not add Cursor to the default panel. Discover exact slugs with `cursor-agent models`; require `default` effort and reject Cursor's `auto` model selector. In step 5, each distinct selected Cursor slug gets one external read-only runner probe from the current parent, in addition to the baseline probes. `cursor-agent status --format json` must confirm authentication, but only a successful model result establishes availability. The CLI must be available as `cursor-agent`; never substitute an unrelated `agent` binary. +Cursor lanes are opt-in and separate from the four baseline families below. Preserve existing `cursor:@default` assignments and collect any named Cursor role additions before probing; do not add Cursor to the default panel. Discover exact slugs with `cursor-agent models`; require `default` effort and reject Cursor's `auto` model selector. In step 5, each distinct selected Cursor slug gets one external read-only runner probe from the current parent, in addition to the baseline probes. `cursor-agent status --format json` must confirm stored OAuth authentication. When `CURSOR_API_KEY` is supplied, the runner instead checks CLI availability and defers authentication to execution; only a successful model result establishes availability. The CLI must be available as `cursor-agent`; never substitute an unrelated `agent` binary. A selected Cursor lane that fails its probe blocks that proposed configuration without changing the active sheet. An unassigned Cursor provider requires neither installation nor probing. Model parsing and final role validation must accept these discovered, probed optional descriptors alongside the baseline matrix families and aliases. Store them directly in the same role map with `@default`; do not ask a separate reasoning-effort question or create another source of truth.