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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/coding-agent/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ curl https://cursor.com/install -fsS | bash

**Multi-account behavior.** Each account gets its own durable credential home under `<agent dir>/cursor-cli-oauth/accounts/<name>/home` (directory mode 0700, `.cursor/auth.json` mode 0600, file credential store), which also holds that account's CLI chat history and is never deleted between turns. One senpi session sticks to one account (rendezvous hashing), and a rate-limited or auth-failing account is blocked with a cooldown while the turn fails over to the next account before any visible output; once output has started, the error surfaces instead of replaying.

**Model switching on resume.** Each turn resumes the same CLI chat id (`--resume`). Switching the model mid-session keeps that chat, and the first post-switch turn carries a short recap block built from senpi's own recent exchanges so the new model re-orients (`contextRecapOnModelSwitch`, default on). A CLI-side context overflow restarts a fresh chat with the same recap instead of wedging the session.
**Model switching on resume.** Each turn resumes the same CLI chat id (`--resume`). Switching the model mid-session keeps that chat, and the first post-switch turn carries a short recap block built from senpi's own recent exchanges so the new model re-orients (`contextRecapOnModelSwitch` or `SENPI_CURSOR_CLI_OAUTH_RECAP`, default on). A CLI-side context overflow restarts a fresh chat with the same recap instead of wedging the session.

**Provider switching.** Entering `cursor-cli-oauth` after another provider also prepends the bounded recent-exchange recap so Cursor receives the intervening Senpi context. This behavior is controlled independently by `contextRecapOnProviderSwitch` (or `SENPI_CURSOR_CLI_OAUTH_PROVIDER_RECAP`, default on). Setting `resumeMode: "off"` remains context-free between consecutive Cursor turns.

**No-approval acknowledgement.** This lane is the one case where a senpi provider runs tools senpi cannot gate: with force execution, the Cursor CLI executes its own tools autonomously - there is no senpi approval, no senpi sandboxing, and no tool-level audit for what it runs. The first force execution therefore refuses with the exact acknowledgement step: `/cursor-account acknowledge` (or set `cursorCliOauthProvider.noApprovalAcknowledgedAt` to the current ISO-8601 timestamp in senpi settings, once). With force execution disabled instead, the lane still answers but the Cursor CLI auto-rejects every tool call (one warning per session).

Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ When unset, senpi leaves provider payloads unchanged. This setting currently app
|---------|------|---------|-------------|
| `claudeSdkOauthProvider.enabled` | boolean | `false` | Enable the ambient (host-CLI-derived) lane of `claude-sdk-oauth`. Env override: `SENPI_CLAUDE_SDK_OAUTH_ENABLED`. Explicit senpi-side logins (stored OAuth accounts in `auth.json`, `CLAUDE_CODE_OAUTH_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN_<n>` env accounts) keep the provider available with this unset |
| `cursorCliOauthProvider.enabled` | boolean | `false` | Enable the `cursor-cli-oauth` fallback lane and automatic native credential bootstrap. Env override: `SENPI_CURSOR_CLI_OAUTH_ENABLED` |
| `cursorCliOauthProvider.contextRecapOnModelSwitch` | boolean | `true` | Prepend a bounded recent-context recap when a resumed Cursor chat changes models. Env override: `SENPI_CURSOR_CLI_OAUTH_RECAP` |
| `cursorCliOauthProvider.contextRecapOnProviderSwitch` | boolean | `true` | Prepend a bounded recent-context recap when entering `cursor-cli-oauth` after another provider. Env override: `SENPI_CURSOR_CLI_OAUTH_PROVIDER_RECAP` |

Both ambient-auth providers are explicit opt-in: a vendor CLI being logged in on the machine is not consent to spend that subscription. Before these gates existed, a logged-in Claude Code or `cursor-agent` CLI made the lane available with no senpi-side action, so subscription usage could flow through a provider you never configured. Env overrides follow the usual precedence (`env > project settings > global settings > default`). See [providers.md](providers.md) for the full lane documentation.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Generated: 2026-08-17
| File | Role |
|---|---|
| `index.ts` | Extension entry: registers the `cursor-cli-oauth` provider unconditionally with the offline model catalog (swapping in the probe-backed catalog when it resolves), wires `/cursor-account`, installs the default native-bootstrap credential reader, and delegates every turn to `streamSimple` with settings, accounts, and the executable re-resolved per turn |
| `settings.ts` | `cursorCliOauthProvider` settings parsing: fixed defaults (`enabled: false`, `forceExecution: true`, `executionMode: "agent"`, `resumeMode: "auto"`, `contextRecapOnModelSwitch: true`, `modelCatalogTtlHours: 24`), `SENPI_CURSOR_CLI_OAUTH_*` env overrides winning over disk, invalid values silently ignored; exports the sandbox-allowlist validator factory |
| `settings.ts` | `cursorCliOauthProvider` settings parsing: fixed defaults (`enabled: false`, `forceExecution: true`, `executionMode: "agent"`, `resumeMode: "auto"`, `contextRecapOnModelSwitch: true`, `contextRecapOnProviderSwitch: true`, `modelCatalogTtlHours: 24`), `SENPI_CURSOR_CLI_OAUTH_*` env overrides winning over disk, invalid values silently ignored; exports the sandbox-allowlist validator factory |
| `accounts.ts` | Multi-account slot model: `CursorCliAccountSlot` and the credential shape with sentinel top-level fields, `addAccount`/`removeAccount`/`pinAccount`/`listAccounts`, name validation `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`, `assertSentinelInvariant`, `refreshSlot` through `store.modify` |
| `native-bootstrap.ts` | Default credential reader: when enabled, the executable resolves, and managed accounts are empty, copies the native Senpi `cursor` OAuth credential into one canonical `native` slot; re-checks the target under the auth lock, deduplicates concurrent reads, never overwrites incompatible/existing credentials, and degrades non-throwingly |
| `affinity.ts` | HRW (rendezvous) account selection keyed by senpi session id (sha256 `BigUInt64BE` score), pinned account wins unless blocked, expired `rate_limit` blocks cleared while `auth_error` blocks persist, `AllCursorAccountsBlockedError` carrying the soonest unblock time |
Expand All @@ -20,7 +20,7 @@ Generated: 2026-08-17
| `transport.ts` | Spawns the resolved executable detached in its own process group with an explicit env allowlist (`HOME` = the account home, `AGENT_CLI_CREDENTIAL_STORE=file`, `PATH`/`TERM`/`LANG`/`LC_ALL`/`FORCE_COLOR`); rejects prompts over 130 KB pre-spawn; abort sends SIGTERM to the group then SIGKILL after 5 s; exposes the pid, parsed events, bounded stderr, and a settled outcome |
| `home-store.ts` | Durable per-account HOMEs under `<agentDir>/cursor-cli-oauth/accounts/<slot>/home`: rewrites `.cursor/auth.json` (`accessToken`/`refreshToken`/`apiKey: null`/`bedrockCredentials: null`) at mode 0600 inside 0700 directories immediately before each run, reads back rotated refresh tokens after; logs byte lengths only; traversal-checked paths; never deletes a HOME |
| `oauth-login.ts` | Provider OAuth config (`check`/`login`/`refreshToken`/`getApiKey`) reusing the `packages/ai` Cursor PKCE flow, first slot named `default`; one `configuredFor` predicate backs both `check` and turn-time lane resolution (`file-store` only, no ambient branch exists); local desktop/keychain import remains explicit, while `importNativeCursorCredential` is shared by explicit and automatic Senpi-native credential copies |
| `session-router.ts` | Sticky chat routing: per-senpi-session `{accountName, chatId, lastModel}` captured from `system/init`; same- or different-model turns resume via `--resume`, a model switch prepends a one-turn 8 KB context recap, and resume failure or `context_overflow` restarts a fresh chat with the recap plus a notice; prompt and recap are shrunk to the transport ceiling before spawning |
| `session-router.ts` | Sticky chat routing: per-senpi-session `{accountName, chatId, lastModel}` captured from `system/init`; same- or different-model turns resume via `--resume`, transitions from another provider and later Cursor model switches independently opt into a one-turn 8 KB context recap, and resume failure or `context_overflow` restarts a fresh chat with the recap plus a notice; prompt and recap are shrunk to the transport ceiling before spawning |
| `failover.ts` | Account rotation around one attempt: `rate_limit` blocks the slot (server hint else 60 s, max 48 h), `auth_error` blocks until re-login; retries only before any visible assistant delta; a replacement account always starts a fresh chat with a user-visible notice and never inherits chat context |
| `models.ts` | Model catalog: cached `cursor-agent models` probe (15 s deadline, full-stdout file capture, ANSI strip, `<id> - <label>` parsing, TTL cache at `<agentDir>/cursor-cli-oauth/models.json`) degrading to the exact 15-entry static fallback; zero cost, text-only input, 64 K max tokens |
| `guardrails.ts` | Execution policy: `--force` only in agent mode with `noApprovalAcknowledgedAt` set (typed `CursorCliExecutionRefusalError` naming the acknowledgement step otherwise); plan mode never forces; force-disabled agent mode and unproven sandbox modes warn once per session; deny lists sanitized to exact full commands and written per-spawn as `permissions.deny` `Shell(...)` entries in the account HOME's `cli-config.json` |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# cursor-cli-oauth extension changes

## 2026-09-01 - Preserve Senpi context on first Cursor CLI turn

### What changed

- `settings.ts`, `stream.ts`, and `session-router.ts`: the turn boundary now detects when the immediately preceding assistant response came from another provider and requests a bounded Senpi context recap, covering both first-time Cursor use and returns to an existing Cursor chat. Provider-switch recap has its own `contextRecapOnProviderSwitch` setting and `SENPI_CURSOR_CLI_OAUTH_PROVIDER_RECAP` environment override (default on), independent from the existing model-switch recap option, and its recap header identifies the provider transition instead of claiming the model changed. Provider transitions still receive a recap when resume mode is disabled; only consecutive Cursor turns remain context-free. The current user prompt is removed from recap composition so it is sent exactly once. Successful Cursor turns persist their account ownership in an assistant diagnostic, so a process restart can restore the ownership proof without persisting transcript text; legacy or missing ownership fails closed. Same-turn failover and cross-turn account reselection both suppress the recap, and replacement accounts are forced off any older sticky chat, preserving the cross-account context-isolation contract.
- `session-router.test.ts`, `stream.test.ts`, and `settings.test.ts`: add regressions for first-time and returning provider switches, independent provider/model recap opt-outs (including resume-fallback and end-to-end provider opt-out), single-copy current prompts on resume fallback, transition-specific recap headers, process-restart ownership recovery, same-turn/cross-turn account isolation, and replacement accounts with stale bindings under auto resume.
- `docs/providers.md` and `docs/settings.md`: document both recap controls and their independent environment overrides.

### Why

- The router previously built recaps only for model switches inside an already-bound Cursor chat. Switching from another provider either had no Cursor routing record or resumed an older Cursor chat, so the CLI received only the latest user message and missed the intervening Senpi conversation.

### Why an extension could not handle it

- This is the builtin extension's private prompt-composition boundary. External hooks cannot access its in-memory Cursor chat binding or alter the subprocess prompt after routing.

### Expected merge conflict zones

- LOW: `settings.ts` around recap toggles, `stream.ts` around turn-input/failover composition, and `session-router.ts` around recap planning, plus their focused test suites.

## 2026-08-24 - Keep provider tool protocol out of assistant text

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CursorCliAbortError, CursorCliPromptTooLargeError, MAX_CURSOR_CLI_PROMP
export const CURSOR_CLI_CONTEXT_RECAP_MAX_BYTES = 8 * 1024;
export const CURSOR_CLI_CONTEXT_RECAP_BEGIN = "===== senpi context recap =====";
export const CURSOR_CLI_CONTEXT_RECAP_END = "===== end senpi context recap =====";
export type CursorCliContextRecapReason = "model-switch" | "provider-switch" | "chat-restart";

export type CursorCliSessionRecord = {
readonly accountName: string;
Expand All @@ -18,6 +19,11 @@ export type CursorCliRecapExchange = {
readonly text: string;
};

export type CursorCliContextOwnership =
| { readonly kind: "new" }
| { readonly kind: "known"; readonly accountName: string }
| { readonly kind: "unknown" };

export type CursorCliSessionTurnContext = {
readonly senpiSessionId: string;
readonly accountName: string;
Expand All @@ -27,11 +33,20 @@ export type CursorCliSessionTurnInput = {
readonly prompt: string;
readonly model: string | undefined;
readonly recentExchanges?: readonly CursorCliRecapExchange[];
/** The immediately preceding assistant turn came from another provider. */
readonly contextRecapRequested?: boolean;
/** Durable account ownership recovered from prior Cursor assistant diagnostics. */
readonly contextOwnership?: CursorCliContextOwnership;
/** A replacement account must never receive transcript context from the failed account. */
readonly contextRecapSuppressed?: boolean;
/** Cross-account failover promises a fresh chat even when this account has an older binding. */
readonly forceFreshChat?: boolean;
};

export type CursorCliSessionPolicy = {
readonly resumeMode?: CursorCliOauthResumeMode;
readonly contextRecapOnModelSwitch?: boolean;
readonly contextRecapOnProviderSwitch?: boolean;
readonly maxRecapBytes?: number;
readonly promptCeilingBytes?: number;
};
Expand All @@ -44,6 +59,7 @@ export type CursorCliTurnPlan = {
readonly contextRecap: string | undefined;
readonly modelSwitch: boolean;
readonly recapDroppedForCeiling: boolean;
readonly freshChatRecapAllowed: boolean;
};

export type CursorCliSessionAttempt = {
Expand Down Expand Up @@ -107,12 +123,17 @@ export function buildCursorCliContextRecap(
model: string | undefined,
exchanges: readonly CursorCliRecapExchange[] | undefined,
maxRecapBytes: number = CURSOR_CLI_CONTEXT_RECAP_MAX_BYTES,
reason: CursorCliContextRecapReason = model === undefined ? "chat-restart" : "model-switch",
): string | undefined {
const usable = (exchanges ?? []).filter((exchange) => exchange.text.length > 0);
if (usable.length === 0) return undefined;
const header = `${CURSOR_CLI_CONTEXT_RECAP_BEGIN}\n(${
model === undefined ? "chat restarted" : `model switched to '${model}'`
}; recent conversation from senpi's own records follows)`;
const transition =
reason === "provider-switch"
? `provider switched to cursor-cli-oauth${model === undefined ? "" : ` using '${model}'`}`
: reason === "chat-restart"
? "chat restarted"
: `model switched to '${model}'`;
const header = `${CURSOR_CLI_CONTEXT_RECAP_BEGIN}\n(${transition}; recent conversation from senpi's own records follows)`;
const overheadBytes = byteLength(header) + 1 + byteLength(CURSOR_CLI_CONTEXT_RECAP_END) + 1;
if (overheadBytes >= maxRecapBytes) return undefined;
const budget = maxRecapBytes - overheadBytes;
Expand Down Expand Up @@ -141,6 +162,12 @@ function composePrompt(recap: string | undefined, prompt: string): string {
return recap === undefined ? prompt : `${recap}\n\n${prompt}`;
}

function priorRecapExchanges(input: CursorCliSessionTurnInput): readonly CursorCliRecapExchange[] | undefined {
return input.recentExchanges?.at(-1)?.role === "user" && input.recentExchanges.at(-1)?.text === input.prompt
? input.recentExchanges.slice(0, -1)
: input.recentExchanges;
}

function shrinkToCeiling(
recap: string | undefined,
rawPrompt: string,
Expand Down Expand Up @@ -267,20 +294,40 @@ export class CursorCliSessionRouter {
const bound = this.records.get(context.senpiSessionId);
// Chats live inside each account's HOME, so a record bound to another
// account can never be resumed here.
const resumable = bound !== undefined && bound.accountName === context.accountName && resumeEnabled;
const resumable =
input.forceFreshChat !== true &&
bound !== undefined &&
bound.accountName === context.accountName &&
resumeEnabled;
const resumeChatId = resumable && bound !== undefined ? bound.chatId : undefined;
const modelSwitch = resumable && bound !== undefined && bound.lastModel !== input.model;
const recap =
modelSwitch && policy.contextRecapOnModelSwitch !== false
? buildCursorCliContextRecap(input.model, input.recentExchanges, policy.maxRecapBytes)
: undefined;
const ownership = input.contextOwnership ?? { kind: "new" };
const sameAccount =
bound !== undefined
? bound.accountName === context.accountName
: ownership.kind === "new" || (ownership.kind === "known" && ownership.accountName === context.accountName);
const providerSwitchRecap =
sameAccount && input.contextRecapRequested === true && policy.contextRecapOnProviderSwitch !== false;
const modelSwitchRecap = modelSwitch && policy.contextRecapOnModelSwitch !== false;
const needsRecap = input.contextRecapSuppressed !== true && (providerSwitchRecap || modelSwitchRecap);
const freshChatRecapAllowed =
input.contextRecapSuppressed !== true && (input.contextRecapRequested !== true || providerSwitchRecap);
const recap = needsRecap
? buildCursorCliContextRecap(
input.model,
priorRecapExchanges(input),
policy.maxRecapBytes,
providerSwitchRecap ? "provider-switch" : "model-switch",
)
: undefined;
const shrunk = shrinkToCeiling(recap, input.prompt, ceilingBytes);
return {
resumeChatId,
prompt: shrunk.prompt,
contextRecap: shrunk.recapDropped ? undefined : recap,
modelSwitch,
recapDroppedForCeiling: shrunk.recapDropped,
freshChatRecapAllowed,
};
}

Expand Down Expand Up @@ -315,6 +362,7 @@ export class CursorCliSessionRouter {
const plan = this.planTurn(context, input, {
resumeMode: options.resumeMode,
contextRecapOnModelSwitch: options.contextRecapOnModelSwitch,
contextRecapOnProviderSwitch: options.contextRecapOnProviderSwitch,
maxRecapBytes: options.maxRecapBytes,
promptCeilingBytes: options.promptCeilingBytes,
});
Expand Down Expand Up @@ -348,7 +396,10 @@ export class CursorCliSessionRouter {
const reason: CursorCliSessionRestartReason =
classification.kind === "context_overflow" ? "context_overflow" : "resume_failed";
const recap =
plan.contextRecap ?? buildCursorCliContextRecap(undefined, input.recentExchanges, options.maxRecapBytes);
plan.contextRecap ??
(plan.freshChatRecapAllowed
? buildCursorCliContextRecap(undefined, priorRecapExchanges(input), options.maxRecapBytes)
: undefined);
const shrunk = shrinkToCeiling(recap, input.prompt, ceilingBytes);
yield restartNotice(previousChatId, reason, recap !== undefined && !shrunk.recapDropped);
attempt = { prompt: shrunk.prompt, resumeChatId: undefined };
Expand Down
Loading