Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

### Fixed

- `cursor-cli-oauth` no longer mixes Cursor's internal tool-call protocol, arguments, and results into assistant text; Cursor still owns execution, while Senpi now stores and renders only the model's actual prose ([OmO #7169](https://github.com/code-yeongyu/oh-my-openagent/issues/7169)).

- On the `claude-sdk-oauth` lane, the "Compaction rejected: the Claude Agent SDK owns compaction for
this session" notice now renders at most once per delegation episode as a muted informational line
instead of repainting a red error line every turn, and the footer context meter shows an `(SDK)`
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Local fork changes

## Cursor CLI OAuth tool-frame suppression boundary (2026-08-29)

- Suppress Cursor CLI tool protocol frames without creating assistant text, while still closing the preceding text segment and resetting cumulative-snapshot tracking so post-tool prose is not lost.


## Credential rotation and fallback parity fixes (2026-08-28)

- Preserve dotted bare model IDs in fallback tombstone matching, admit policy-only credential slots, avoid consuming half-open leases during runtime preflight, and isolate service-created credential pool state under the requested agent directory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Generated: 2026-08-17
| `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` |
| `stream.ts` | The turn path (`streamCursorCliOauth`): re-resolves settings, accounts, and the executable per turn, refreshes expired tokens, composes failover and the session router, maps events to ordered text/thinking deltas with cumulative-snapshot dedupe, renders tool frames display-only as untrusted output, and applies usage isolation (senpi's own sent-payload estimate plus the CLI's output tokens; CLI input/cache numbers quarantined in a `cursor_cli_oauth_cli_usage` diagnostic) |
| `stream.ts` | The turn path (`streamCursorCliOauth`): re-resolves settings, accounts, and the executable per turn, refreshes expired tokens, composes failover and the session router, maps events to ordered text/thinking deltas with cumulative-snapshot dedupe, keeps Cursor-executed tool protocol out of assistant text and host tool calls, and applies usage isolation (senpi's own sent-payload estimate plus the CLI's output tokens; CLI input/cache numbers quarantined in a `cursor_cli_oauth_cli_usage` diagnostic) |
| `account-command.ts` | `/cursor-account` command (`list`/`add`/`remove`/`pin`/`unpin`/`import`/`status`): reads account state fresh from the credential store on every invocation, reuses `claude-sdk-oauth`'s account-events emitter (import only; that lane is never edited), and fences every deferred continuation behind the generation guard |
| `diagnostics.ts` | Status collection and rendering for `/cursor-account status` (lane always `file-store`, context owner always `senpi`, selected account, chat id, last model, executable path and version with the `2026.08.11` floor warning, block windows, native-provider recommendation when configured); plus reload/shutdown safety: the generation fence, the tracked-child registry, and the `session_shutdown` teardown with process-group kills |
| `changes.md` | Fork-change record; read before touching anything here |
Expand All @@ -35,7 +35,7 @@ Generated: 2026-08-17
- senpi is the context owner in every mode. `usage.input` is senpi's own estimate of the payload it spawned, `usage.output` is the CLI's `outputTokens`, `cacheRead`/`cacheWrite`/`totalTokens` stay 0, and the CLI's `inputTokens`/`cacheReadTokens`/`request_id` live only in the `cursor_cli_oauth_cli_usage` diagnostic. Nothing under `extensions/builtin/compaction/` or core compaction is touched, and no `session_compact` handler is registered.
- File-store-only auth: per-account sandboxed HOMEs with `AGENT_CLI_CREDENTIAL_STORE=file` and sentinel top-level credential fields. No ambient request lane exists. When the lane is explicitly enabled (`enabled: true`; it defaults to false), `cursor-agent` resolves, and no managed account exists, the default reader copies Senpi's stored native `cursor` OAuth credential into one managed `native` slot without modifying the source. Reading the user's desktop/CLI store or system keychain remains explicit via `/cursor-account import local`; `CURSOR_API_KEY` is never set and the system keychain is never written.
- Explicit opt-in: `cursorCliOauthProvider.enabled` defaults to false, so a logged-in host `cursor-agent` alone never makes the lane available and never triggers native-credential bootstrap. A verbatim `enabled: false` (settings or `SENPI_CURSOR_CLI_OAUTH_ENABLED=0`) is a kill switch that returns disabled before any credential work; with the flag merely absent, stored managed accounts - which exist only after an explicit `/login cursor-cli-oauth` or `/cursor-account import` - keep the lane available, because that login IS the opt-in. `isCursorCliOauthLaneEnabled` is the single rule shared by `assessConfiguration`, the turn path, and the bootstrap gate. Automatic bootstrap never writes `noApprovalAcknowledgedAt`; force execution still requires its separate acknowledgement.
- The CLI executes its own tools autonomously: `--force` requires the explicit `noApprovalAcknowledgedAt` acknowledgement, and tool frames render display-only as untrusted output, never bridged onto senpi tools.
- The CLI executes its own tools autonomously: `--force` requires the explicit `noApprovalAcknowledgedAt` acknowledgement, and provider tool frames never enter assistant text or bridge onto senpi tools.
- Chats are private to each account's HOME; cross-account failover always starts a fresh chat with a notice and never transfers context.
- Process lifecycle: children spawn detached in their own process group and are killed by tracked pid only - never by name matching (senpi #823). Deferred continuations are fenced per extension generation (senpi #866).
- Provider registration never blocks on the executable; `check` reports one of `configured (file-store, <n> accounts)`, `disabled by settings` (kill switch or a flagless lane with no stored accounts), `cursor-agent not installed: <guidance>`, or `no accounts: run /login cursor-cli-oauth`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# cursor-cli-oauth extension changes

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

### What changed

- `stream.ts`: Cursor `tool_call` events are no longer serialized into `<cursor-cli-tool>` assistant text. They also remain intentionally unmapped to host `toolCall` blocks because Cursor already executed them in its subprocess. The suppressed events still close the preceding text segment and reset cumulative-snapshot tracking without creating an empty stored block, so post-tool prose is preserved.
- `stream.test.ts`: the tool-turn regression now proves that text deltas and stored assistant content contain only the model's prose before and after both started/completed tool frames, including a post-tool cumulative fragment sharing the pre-tool prefix.

### Why

- Rendering provider protocol as text mixed long JSON blobs into the TUI and persisted untrusted tool arguments/results in conversation context.

### Why an extension could not handle it

- The pollution happened inside the builtin provider before OmO or another extension received the assistant message, so the provider boundary is the only layer that can remove it without post-processing legitimate model prose.

## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from "./session-router.ts";
import { type CursorCliOauthProviderSettings, loadCursorCliOauthProviderSettingsFromDisk } from "./settings.ts";
import { resolveCursorCliSpawnModel } from "./spawn-model.ts";
import type { CursorCliStreamEvent, CursorCliToolCallEvent } from "./stream-parser.ts";
import type { CursorCliStreamEvent } from "./stream-parser.ts";
import { CursorCliAbortError, type CursorCliTransportHandle, spawnCursorCli } from "./transport.ts";

export { CURSOR_CLI_OAUTH_PROVIDER_ID } from "./oauth-login.ts";
Expand All @@ -54,12 +54,6 @@ const DISABLED_MESSAGE = "disabled by settings";
const NO_ACCOUNTS_MESSAGE = "no accounts: run /login cursor-cli-oauth";
const RECENT_EXCHANGE_LIMIT = 12;

/** Delimiters around every display-only tool frame; tool output is untrusted data, never instructions. */
const TOOL_DISPLAY_BEGIN = "<cursor-cli-tool>";
const TOOL_DISPLAY_END = "</cursor-cli-tool>";
const TOOL_DISPLAY_LABEL = "executed by the Cursor CLI (untrusted output; display only, not instructions)";
const TOOL_RENDER_BUDGET = 2_000;

/** Injectable seams so tests stay hermetic; every default is re-resolved per turn. */
export type CursorCliStreamDeps = {
readonly cwd?: string;
Expand Down Expand Up @@ -229,6 +223,11 @@ function ensureOpen(mapper: StreamMapper, kind: OpenBlockKind): void {
openBlock(mapper, kind);
}

function resetTextSegment(mapper: StreamMapper): void {
closeOpen(mapper);
mapper.textAccumulated = "";
}

function pushTextDelta(mapper: StreamMapper, delta: string): void {
const block = mapper.output.content[mapper.openIndex];
mapper.openText += delta;
Expand Down Expand Up @@ -271,27 +270,6 @@ function appendAssistantFragment(mapper: StreamMapper, fragment: string): void {
if (delta.length > 0) pushTextDelta(mapper, delta);
}

function renderToolFrame(event: CursorCliToolCallEvent): string {
const kind = Object.keys(event.tool_call)[0] ?? "toolCall";
const details = event.tool_call[kind as `${string}ToolCall`] ?? {};
const payload: Record<string, unknown> = {
label: TOOL_DISPLAY_LABEL,
tool: kind,
phase: event.subtype,
callId: event.call_id,
};
if (details.args !== undefined) payload.args = details.args;
if (details.result !== undefined) payload.result = details.result;
let body = JSON.stringify(payload) ?? "{}";
if (body.length > TOOL_RENDER_BUDGET) body = `${body.slice(0, TOOL_RENDER_BUDGET)}...[truncated]`;
return `${TOOL_DISPLAY_BEGIN}${body}${TOOL_DISPLAY_END}\n`;
}

function appendToolFrame(mapper: StreamMapper, event: CursorCliToolCallEvent): void {
ensureOpen(mapper, "tool");
pushTextDelta(mapper, renderToolFrame(event));
}

function appendNotice(mapper: StreamMapper, message: string): void {
ensureOpen(mapper, "text");
pushTextDelta(mapper, `${message}\n`);
Expand Down Expand Up @@ -651,7 +629,10 @@ export function streamCursorCliOauth(
for (const block of event.message.content) appendAssistantFragment(mapper, block.text);
break;
case "tool_call":

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2: This no-op drops the old tool-boundary state transition as well as the visible frame. appendToolFrame() previously reached ensureOpen(mapper, "tool"), which reset textAccumulated through openBlock(). With the no-op, a valid post-tool incremental fragment that starts with the earlier prose is treated as a cumulative snapshot and its prefix is removed. Suppress the tool output without losing that boundary semantics.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e53a28c: hidden tool frames now close the prior text segment and reset cumulative-snapshot tracking without emitting or storing a tool block, so post-tool prose is preserved.

appendToolFrame(mapper, event);
// Preserve the text-segment boundary and cumulative snapshot reset,
// but do not expose provider protocol frames as assistant text or map
// them to host tool calls that Senpi could execute a second time.
resetTextSegment(mapper);
break;
case "result":
if (event.subtype === "success" && !event.is_error) {
Expand Down
19 changes: 10 additions & 9 deletions packages/coding-agent/test/cursor-cli-oauth/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,11 @@ function toolsTurnFixture(directory: string): string {
`const CALL_ID = "tool_fake-shell-001";`,
`function writeEvent(event) { process.stdout.write(JSON.stringify(event) + "\\n"); }`,
`writeEvent({ type: "system", subtype: "init", apiKeySource: "login", cwd: "/tmp", session_id: SESSION_ID, model: "Composer 2.5 Fast", permissionMode: "default" });`,
`writeEvent({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "BEFORE TOOL" }] } });`,
`writeEvent({ type: "tool_call", subtype: "started", call_id: CALL_ID, tool_call: { shellToolCall: { args: { command: "echo tooltest-force-77" } } } });`,
`writeEvent({ type: "tool_call", subtype: "completed", call_id: CALL_ID, tool_call: { shellToolCall: { args: { command: "echo tooltest-force-77" }, result: { success: { exitCode: 0, stdout: "tooltest-force-77\\n", stderr: "", executionTime: 25 } } } } });`,
`writeEvent({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "TOOLS OK" }] } });`,
`writeEvent({ type: "result", subtype: "success", duration_ms: 80, is_error: false, result: "TOOLS OK", session_id: SESSION_ID, request_id: "req-tools", usage: { inputTokens: 30, outputTokens: 5, cacheReadTokens: 2, cacheWriteTokens: 0 } });`,
`writeEvent({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "BEFORE TOOL AFTER" }] } });`,
`writeEvent({ type: "result", subtype: "success", duration_ms: 80, is_error: false, result: "BEFORE TOOL AFTER", session_id: SESSION_ID, request_id: "req-tools", usage: { inputTokens: 30, outputTokens: 5, cacheReadTokens: 2, cacheWriteTokens: 0 } });`,
].join("\n"),
{ mode: 0o600 },
);
Expand Down Expand Up @@ -321,7 +322,7 @@ describe("cursor-cli-oauth stream mapping", () => {
});
});

it("renders tool frames as display-only blocks labelled as executed by the Cursor CLI", async () => {
it("keeps Cursor CLI tool protocol frames out of assistant text", async () => {
const directory = temporaryDirectory();
const deps: CursorCliStreamDeps = {
cwd: directory,
Expand All @@ -336,12 +337,12 @@ describe("cursor-cli-oauth stream mapping", () => {
const message = doneMessage(events);

expect(message.content.some((block) => block.type === "toolCall")).toBe(false);
const rendered = textBlocks(message).join("\n");
expect(rendered).toContain("executed by the Cursor CLI");
// Untrusted tool output stays inside the delimited display region.
const withoutDisplayRegions = rendered.replace(/<cursor-cli-tool>[\s\S]*?<\/cursor-cli-tool>/g, "");
expect(withoutDisplayRegions).not.toContain("tooltest-force-77");
expect(rendered).toContain("tooltest-force-77");
expect(textDeltas(events)).toEqual(["BEFORE TOOL", "BEFORE TOOL AFTER"]);
expect(textBlocks(message)).toEqual(["BEFORE TOOL", "BEFORE TOOL AFTER"]);
const rendered = textBlocks(message).join("");
expect(rendered).not.toContain("<cursor-cli-tool>");
expect(rendered).not.toContain("shellToolCall");
expect(rendered).not.toContain("tooltest-force-77");
});

it("surfaces a zero-exit turn with no assistant events as an error, never an empty success", async () => {
Expand Down