feat(ai): handle Cursor's modern exec wire protocol

Current Cursor CLI builds emit exec frames this client did not model. A
frame whose oneof number is absent from `agent.proto` decodes with
`message.case` unset, so the dispatcher found no handler, ran no tool and
sent no result — the server was left waiting on an execution that never
happened.

Every recognised frame now gets a typed answer:

- The seven Pi tools (45-51) run their local equivalents. They are a
  separate wire family from the legacy args, not aliases: `pi_grep`'s
  `ignore_case` is the inverse of the local `case` flag, `pi_find`
  searches filenames (so it routes to `glob`, not `grep`), and
  `pi_edit`'s replacements are renamed to snake_case pairs.
- Hooks, subagents, prechecks, MCP state, smart-mode, canvas,
  conversation search and agent-store answer with the error, not-found or
  empty-but-valid variant that is true of this client.
- Unnameable frames raise `ExecClientControlMessage.throw`
  (`unknown_exec_variant`); recognised frames with no truthful answer —
  `git_diff_request`, whose `GetDiffResponse` has no error variant —
  raise `exec_variant_unsupported`.

Four frames previously answered `create(XSchema, {})`. In proto3 that is
not an empty result: the oneof is unset and the server reads it as "the
tool ran and produced nothing", indistinguishable from success. They now
send real variants.

`connect_scm` lost its repository (the target rides in a oneof, so the
flat property was always undefined) and settled on a fixed failure at the
announcement, before the server's `success`/`error`/`rejected` verdict
arrived on the completion frame.

The stream decoder tracked a single "current" tool-call block and settled
it on any `toolCallCompleted`, ignoring the envelope `call_id`: an
unrelated completion paired the wrong block, and `start A, start B`
orphaned A so nothing ever paired it — which strips the whole interaction
from every rebuilt transcript. Blocks are now retained per envelope id.

`lsp` is advertised as MCP again; the native `diagnostics` frame covers
one of ~10 actions.

(cherry picked from commit 4d269724a3a448886d13b4323ac02aadbfe38de3)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 07:45:02 -03:00
committed by can1357
parent cdbe7c4ed2
commit b6e01c8a3c
15 changed files with 2997 additions and 59 deletions
+12
View File
@@ -63,6 +63,18 @@
- Kimi Code now sends its session-stable prompt cache key on both supported transports: `prompt_cache_key` for OpenAI-compatible requests and `metadata.user_id` for Anthropic-compatible requests. Explicit keys survive side-channel session IDs, while `cacheRetention: "none"` still disables automatic affinity ([#6049](https://github.com/can1357/oh-my-pi/issues/6049)).
- Fresh encrypted auth-broker snapshot caches are revalidated within a short startup budget, so one-shot clients see newly imported or revoked credentials immediately when the broker is reachable while retaining cache fallback for transport and server failures.
- Fixed custom `anthropic-messages` endpoints dropping native web-search call/result blocks in the leaked-thinking wrapper, preserving signed continuation history in source order without carrying a preceding text signature onto later unsigned blocks ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)).
### Added
- Cursor's modern exec wire protocol is now handled end to end. `agent.proto` models the frames current Cursor CLI builds emit — the seven Pi tools (`ExecServerMessage` 45-51), hooks, subagents, allowlist prechecks, MCP state, smart-mode classification, canvas diagnostics, conversation search, agent-store conflicts and git diff — and every one of them gets a typed answer. The Pi frames run their local equivalents (`read`/`bash`/`edit`/`write`/`grep`/`glob`); the rest answer with the error, not-found or empty-but-valid variant that is actually true of this client. Frames this build cannot name at all now raise `ExecClientControlMessage.throw` with `unknown_exec_variant`, and recognised frames with no truthful answer (`git_diff_request`, whose `GetDiffResponse` has no error variant) raise `exec_variant_unsupported`, instead of a silent ack that leaves the server waiting.
- `lsp` is advertised in the MCP tool catalog again. It was filtered out as a Cursor-native tool, but the native `diagnostics` frame covers one of roughly ten LSP actions, so the other nine were unreachable.
### Fixed
- Fixed four Cursor exec frames answering with a result whose oneof was never set. In proto3 that is not an empty result — the server reads it as "the tool ran and produced nothing", indistinguishable from real success. `listMcpResourcesExecResult`, `readMcpResourceExecResult`, `recordScreenResult` and `computerUseResult` now send `ListMcpResourcesSuccess{resources: []}`, `ReadMcpResourceNotFound{uri}`, `RecordScreenFailure` and `ComputerUseError` respectively.
- Fixed Cursor `connect_scm` calls losing their repository and settling on a fabricated verdict. The target rides in the `ConnectScmArgs.target` oneof, so reading a flat `github` property always saw `undefined`; and the authoritative `success`/`error`/`rejected` result only arrives on the completion frame, so answering at the announcement persisted a fixed failure for every call — including the ones the server went on to accept. The block now opens on the start frame and settles from the completion's decoded result.
- Fixed interleaved Cursor tool calls corrupting each other. The stream decoder tracked a single "current" block and settled it on any `toolCallCompleted`, ignoring the envelope's `call_id`: a completion for one call closed whichever block happened to be open and paired it with the wrong result, and `start A, start B` orphaned A entirely so its own completion settled B while A was never paired — which strips the whole interaction from every rebuilt transcript. Open blocks are now retained per envelope `call_id`, and end-of-stream closes all of them rather than only the last.
- Fixed a Cursor `search_conversations` call leaving no transcript block. The frame is answered from a fixed verdict, so nothing downstream pairs a result for it, and an unpaired call takes its whole interaction out of every rebuilt transcript.
- Fixed the streamed `pi_*_tool_call` announcements that modern builds send alongside each exec frame being unrecognized. The exec channel already synthesizes those blocks when it runs the tool; the duplicate was avoided only because the decoder recognized none of the variants, which would have started double-rendering as soon as any one was added.
## [17.1.4] - 2026-07-26
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,393 @@
/**
* Proto builders for the modern Cursor CLI exec frames (`ExecServerMessage`
* 27-31, 36-38, 40-55).
*
* Split out of `cursor.ts` because these are pure `create(...)` shapes with no
* transport, stream, or block-state coupling: the dispatcher decides *which*
* answer a frame gets, this module knows *what* that answer looks like on the
* wire. Every builder returns a result whose oneof case is set — an
* `ExecClientMessage` carrying a result with an unset oneof is a fake success
* the server reads as "the tool ran and produced nothing".
*/
import { create } from "@bufbuild/protobuf";
import {
AfterAgentResponseRequestResponseSchema,
AfterAgentThoughtRequestResponseSchema,
BeforeSubmitPromptRequestResponseSchema,
type ExecuteHookRequest,
type ExecuteHookResponse,
ExecuteHookResponseSchema,
type ExecuteHookResult,
ExecuteHookResultSchema,
type McpStateExecResult,
McpStateExecResultSchema,
McpStateServerSchema,
McpStateSuccessSchema,
type McpToolDefinition,
PiBashExecErrorSchema,
type PiBashExecResult,
PiBashExecResultSchema,
PiBashExecSuccessSchema,
PiEditExecErrorSchema,
type PiEditExecResult,
PiEditExecResultSchema,
PiEditExecSuccessSchema,
PiFindExecErrorSchema,
type PiFindExecResult,
PiFindExecResultSchema,
PiFindExecSuccessSchema,
PiGrepExecErrorSchema,
type PiGrepExecResult,
PiGrepExecResultSchema,
PiGrepExecSuccessSchema,
PiLsExecErrorSchema,
type PiLsExecResult,
PiLsExecResultSchema,
PiLsExecSuccessSchema,
PiReadExecErrorSchema,
type PiReadExecResult,
PiReadExecResultSchema,
PiReadExecSuccessSchema,
type PiTruncation,
PiTruncationSchema,
PiWriteExecErrorSchema,
type PiWriteExecResult,
PiWriteExecResultSchema,
PiWriteExecSuccessSchema,
PostToolUseFailureRequestResponseSchema,
PostToolUseRequestResponseSchema,
PreCompactRequestResponseSchema,
PreToolUseRequestResponseSchema,
StopRequestResponseSchema,
SubagentStartRequestResponseSchema,
SubagentStopRequestResponseSchema,
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
import type { ToolResultMessage } from "../../types";
/** Flatten a tool result's content into the single `output` string the Pi frames carry. */
export function piOutputText(toolResult: ToolResultMessage): string {
return toolResult.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
}
/**
* Read one field off a tool result's `details` bag, or off a nested object
* inside it.
*
* `details` is `unknown` by design — every tool ships its own shape — so each
* read is narrowed rather than asserted, and the caller decides what a value of
* the wrong type means.
*/
function bagValue(bag: unknown, key: string): unknown {
if (!bag || typeof bag !== "object" || !(key in bag)) return undefined;
return Reflect.get(bag, key);
}
/**
* A positive integer count from `details`, or `undefined`.
*
* The Pi frames model their limit counters as `optional uint32`: zero means
* "the limit was reached at zero results", so a missing, non-numeric, or
* non-positive value must stay unset rather than be sent as 0.
*/
function detailCount(toolResult: ToolResultMessage, key: string): number | undefined {
const value = bagValue(toolResult.details, key);
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
/**
* Translate a local tool's truncation summary
* (`coding-agent/src/session/streaming-output.ts:TruncationResult`) into
* `PiTruncation`.
*
* Returns `undefined` when nothing was truncated: the field is `optional` on
* every Pi success message, and emitting a zeroed `PiTruncation` would tell the
* server the output was trimmed to nothing.
*/
export function piTruncation(toolResult: ToolResultMessage): PiTruncation | undefined {
const truncation = bagValue(toolResult.details, "truncation");
if (bagValue(truncation, "truncated") !== true) return undefined;
const truncatedBy = bagValue(truncation, "truncatedBy");
const totalLines = bagValue(truncation, "totalLines");
const outputLines = bagValue(truncation, "outputLines");
const outputBytes = bagValue(truncation, "outputBytes");
return create(PiTruncationSchema, {
truncated: true,
truncatedBy: typeof truncatedBy === "string" ? truncatedBy : "",
totalLines: typeof totalLines === "number" ? totalLines : 0,
outputLines: typeof outputLines === "number" ? outputLines : 0,
outputBytes: typeof outputBytes === "number" ? outputBytes : 0,
firstLineExceedsLimit: bagValue(truncation, "firstLineExceedsLimit") === true,
lastLinePartial: bagValue(truncation, "lastLinePartial") === true,
});
}
export function buildPiReadResult(toolResult: ToolResultMessage): PiReadExecResult {
const text = piOutputText(toolResult);
if (toolResult.isError) return buildPiReadError(text || "Read failed");
return create(PiReadExecResultSchema, {
result: {
case: "success",
value: create(PiReadExecSuccessSchema, { output: text, truncation: piTruncation(toolResult) }),
},
});
}
export function buildPiReadError(error: string): PiReadExecResult {
return create(PiReadExecResultSchema, {
result: { case: "error", value: create(PiReadExecErrorSchema, { error }) },
});
}
export function buildPiBashResult(toolResult: ToolResultMessage): PiBashExecResult {
const text = piOutputText(toolResult);
const truncation = piTruncation(toolResult);
if (toolResult.isError) {
return create(PiBashExecResultSchema, {
result: {
case: "error",
value: create(PiBashExecErrorSchema, { error: text || "Command failed", truncation }),
},
});
}
return create(PiBashExecResultSchema, {
result: {
case: "success",
value: create(PiBashExecSuccessSchema, { output: text, truncation }),
},
});
}
export function buildPiBashError(error: string): PiBashExecResult {
return create(PiBashExecResultSchema, {
result: { case: "error", value: create(PiBashExecErrorSchema, { error }) },
});
}
/**
* `PiEditExecSuccess` requires `diff` and `patch` alongside `output`. The local
* `edit` tool reports them under `details`; when it does not, the strings stay
* empty rather than being faked from the output text.
*/
export function buildPiEditResult(toolResult: ToolResultMessage): PiEditExecResult {
const text = piOutputText(toolResult);
if (toolResult.isError) return buildPiEditError(text || "Edit failed");
const diff = bagValue(toolResult.details, "diff");
const patch = bagValue(toolResult.details, "patch");
return create(PiEditExecResultSchema, {
result: {
case: "success",
value: create(PiEditExecSuccessSchema, {
output: text,
diff: typeof diff === "string" ? diff : "",
patch: typeof patch === "string" ? patch : "",
firstChangedLine: detailCount(toolResult, "firstChangedLine"),
}),
},
});
}
export function buildPiEditError(error: string): PiEditExecResult {
return create(PiEditExecResultSchema, {
result: { case: "error", value: create(PiEditExecErrorSchema, { error }) },
});
}
export function buildPiWriteResult(toolResult: ToolResultMessage): PiWriteExecResult {
const text = piOutputText(toolResult);
if (toolResult.isError) return buildPiWriteError(text || "Write failed");
return create(PiWriteExecResultSchema, {
result: { case: "success", value: create(PiWriteExecSuccessSchema, { output: text }) },
});
}
export function buildPiWriteError(error: string): PiWriteExecResult {
return create(PiWriteExecResultSchema, {
result: { case: "error", value: create(PiWriteExecErrorSchema, { error }) },
});
}
export function buildPiGrepResult(toolResult: ToolResultMessage): PiGrepExecResult {
const text = piOutputText(toolResult);
if (toolResult.isError) return buildPiGrepError(text || "Grep failed");
return create(PiGrepExecResultSchema, {
result: {
case: "success",
value: create(PiGrepExecSuccessSchema, {
output: text,
truncation: piTruncation(toolResult),
matchLimitReached: detailCount(toolResult, "perFileLimitReached"),
linesTruncated: bagValue(toolResult.details, "linesTruncated") === true,
}),
},
});
}
export function buildPiGrepError(error: string): PiGrepExecResult {
return create(PiGrepExecResultSchema, {
result: { case: "error", value: create(PiGrepExecErrorSchema, { error }) },
});
}
export function buildPiFindResult(toolResult: ToolResultMessage): PiFindExecResult {
const text = piOutputText(toolResult);
if (toolResult.isError) return buildPiFindError(text || "Find failed");
return create(PiFindExecResultSchema, {
result: {
case: "success",
value: create(PiFindExecSuccessSchema, {
output: text,
truncation: piTruncation(toolResult),
resultLimitReached: detailCount(toolResult, "resultLimitReached"),
}),
},
});
}
export function buildPiFindError(error: string): PiFindExecResult {
return create(PiFindExecResultSchema, {
result: { case: "error", value: create(PiFindExecErrorSchema, { error }) },
});
}
export function buildPiLsResult(toolResult: ToolResultMessage): PiLsExecResult {
const text = piOutputText(toolResult);
if (toolResult.isError) return buildPiLsError(text || "Ls failed");
return create(PiLsExecResultSchema, {
result: {
case: "success",
value: create(PiLsExecSuccessSchema, {
output: text,
truncation: piTruncation(toolResult),
entryLimitReached: detailCount(toolResult, "resultLimitReached"),
}),
},
});
}
export function buildPiLsError(error: string): PiLsExecResult {
return create(PiLsExecResultSchema, {
result: { case: "error", value: create(PiLsExecErrorSchema, { error }) },
});
}
/**
* Answer `mcpStateExecArgs` (frame 36) from the catalog already advertised in
* `RequestContext.tools`.
*
* This client hosts no MCP servers of its own: every forwarded tool is a local
* pi-agent tool published under a synthetic `providerIdentifier`. Regrouping
* the same list keeps the server's view of "which servers exist and what do
* they expose" consistent with what it was told at context time, instead of
* claiming zero servers while tool calls for them keep arriving.
*
* `serverIdentifiers` filters the answer when the server asks about specific
* servers. `kickOnly` is a restart request — there is nothing to restart, so it
* is answered with the same state rather than an error.
*/
export function buildMcpStateResult(
tools: McpToolDefinition[],
serverIdentifiers: readonly string[],
): McpStateExecResult {
const byProvider = new Map<string, McpToolDefinition[]>();
for (const tool of tools) {
const identifier = tool.providerIdentifier;
const existing = byProvider.get(identifier);
if (existing) existing.push(tool);
else byProvider.set(identifier, [tool]);
}
const wanted = serverIdentifiers.length > 0 ? new Set(serverIdentifiers) : undefined;
const servers = [];
for (const [identifier, serverTools] of byProvider) {
if (wanted && !wanted.has(identifier)) continue;
servers.push(
create(McpStateServerSchema, {
serverName: identifier,
serverIdentifier: identifier,
tools: serverTools,
status: "connected",
}),
);
}
return create(McpStateExecResultSchema, {
result: { case: "success", value: create(McpStateSuccessSchema, { servers }) },
});
}
/**
* Build the neutral response for a hook query: the matching response case with
* every field unset.
*
* This client runs no Cursor hooks, and every field of every response variant
* is `optional` — so an empty response of the right case means "no hook had
* anything to say", which is exactly true. It is NOT the unset-oneof fake
* success: the case itself is set, only the payload is empty.
*
* `ExecuteHookRequest` and `ExecuteHookResponse` are parallel oneofs whose case
* names line up, but the two unions are unrelated to the compiler: a `switch`
* is what makes each pairing individually type-checked, and it forces a
* deliberate branch when a future regen adds a request case.
*
* Returns `null` for a request case this build does not model, which the
* dispatcher answers with `ExecClientThrow` rather than guessing a case.
*/
export function buildNeutralHookResult(request: ExecuteHookRequest | undefined): ExecuteHookResult | null {
let response: ExecuteHookResponse;
switch (request?.request.case) {
case "preCompact":
response = create(ExecuteHookResponseSchema, {
response: { case: "preCompact", value: create(PreCompactRequestResponseSchema, {}) },
});
break;
case "subagentStart":
response = create(ExecuteHookResponseSchema, {
response: { case: "subagentStart", value: create(SubagentStartRequestResponseSchema, {}) },
});
break;
case "subagentStop":
response = create(ExecuteHookResponseSchema, {
response: { case: "subagentStop", value: create(SubagentStopRequestResponseSchema, {}) },
});
break;
case "preToolUse":
response = create(ExecuteHookResponseSchema, {
response: { case: "preToolUse", value: create(PreToolUseRequestResponseSchema, {}) },
});
break;
case "postToolUse":
response = create(ExecuteHookResponseSchema, {
response: { case: "postToolUse", value: create(PostToolUseRequestResponseSchema, {}) },
});
break;
case "postToolUseFailure":
response = create(ExecuteHookResponseSchema, {
response: { case: "postToolUseFailure", value: create(PostToolUseFailureRequestResponseSchema, {}) },
});
break;
case "beforeSubmitPrompt":
response = create(ExecuteHookResponseSchema, {
response: { case: "beforeSubmitPrompt", value: create(BeforeSubmitPromptRequestResponseSchema, {}) },
});
break;
case "afterAgentResponse":
response = create(ExecuteHookResponseSchema, {
response: { case: "afterAgentResponse", value: create(AfterAgentResponseRequestResponseSchema, {}) },
});
break;
case "afterAgentThought":
response = create(ExecuteHookResponseSchema, {
response: { case: "afterAgentThought", value: create(AfterAgentThoughtRequestResponseSchema, {}) },
});
break;
case "stop":
response = create(ExecuteHookResponseSchema, {
response: { case: "stop", value: create(StopRequestResponseSchema, {}) },
});
break;
default:
return null;
}
return create(ExecuteHookResultSchema, { response });
}
@@ -417,7 +417,21 @@ message ToolCall {
TruncatedToolCall truncated_tool_call = 34;
StartGrindExecutionToolCall start_grind_execution_tool_call = 35;
StartGrindPlanningToolCall start_grind_planning_tool_call = 36;
PiReadToolCall pi_read_tool_call = 61;
PiBashToolCall pi_bash_tool_call = 62;
PiEditToolCall pi_edit_tool_call = 63;
PiWriteToolCall pi_write_tool_call = 64;
PiGrepToolCall pi_grep_tool_call = 65;
PiFindToolCall pi_find_tool_call = 66;
PiLsToolCall pi_ls_tool_call = 67;
ConnectScmToolCall connect_scm_tool_call = 68;
SearchConversationsToolCall search_conversations_tool_call = 69;
}
// Modern builds carry the call id on the envelope instead of inside each
// variant's args. Fields 37-53, 55, 56, 58 (further tool variants) and 54
// (hook_additional_contexts), 59/60 (started_at_ms/completed_at_ms) are not
// modelled and decode into unknown fields; do not reuse those numbers.
optional string tool_call_id = 57;
}
message TruncatedToolCallArgs {
@@ -4426,3 +4440,94 @@ message GetDiffResponse {
optional string head_sha = 4;
optional bool has_uncommitted_changes = 5;
}
// ===== Pi tool calls (ToolCall 61-67) =====
//
// The streamed `ToolCall` wrappers for the Pi exec frames. Their `args`/`result`
// payloads are field-identical to the `Pi*Exec*` messages above (verified against
// the modern CLI's `pi_*_tool_pb.js`), and the exec channel is binary protobuf,
// so field numbers — not type names — define wire compatibility. Reusing the
// exec messages keeps one shape per Pi tool instead of two that must stay in sync.
message PiReadToolCall {
PiReadExecArgs args = 1;
PiReadExecResult result = 2;
}
message PiBashToolCall {
PiBashExecArgs args = 1;
PiBashExecResult result = 2;
}
message PiEditToolCall {
PiEditExecArgs args = 1;
PiEditExecResult result = 2;
}
message PiWriteToolCall {
PiWriteExecArgs args = 1;
PiWriteExecResult result = 2;
}
message PiGrepToolCall {
PiGrepExecArgs args = 1;
PiGrepExecResult result = 2;
}
message PiFindToolCall {
PiFindExecArgs args = 1;
PiFindExecResult result = 2;
}
message PiLsToolCall {
PiLsExecArgs args = 1;
PiLsExecResult result = 2;
}
// ===== Conversation search / SCM tool calls (ToolCall 68-69) =====
message SearchConversationsToolCall {
ConversationSearchArgs args = 1;
ConversationSearchResult result = 2;
}
message ConnectScmGithubRepository {
string owner = 1;
string repo = 2;
}
message ConnectScmGithub {
ConnectScmGithubRepository repository = 1;
optional string ghe_application = 2;
}
message ConnectScmArgs {
string tool_call_id = 1;
oneof target {
ConnectScmGithub github = 2;
}
}
message ConnectScmSuccess {
}
message ConnectScmError {
string error = 1;
}
message ConnectScmRejected {
string reason = 1;
}
message ConnectScmResult {
oneof result {
ConnectScmSuccess success = 1;
ConnectScmError error = 2;
ConnectScmRejected rejected = 3;
}
}
message ConnectScmToolCall {
ConnectScmArgs args = 1;
ConnectScmResult result = 2;
}
+41
View File
@@ -11,6 +11,20 @@ import type {
LsArgs,
LsResult,
McpResult,
PiBashExecArgs,
PiBashExecResult,
PiEditExecArgs,
PiEditExecResult,
PiFindExecArgs,
PiFindExecResult,
PiGrepExecArgs,
PiGrepExecResult,
PiLsExecArgs,
PiLsExecResult,
PiReadExecArgs,
PiReadExecResult,
PiWriteExecArgs,
PiWriteExecResult,
ReadArgs,
ReadResult,
ShellArgs,
@@ -989,6 +1003,21 @@ export interface CursorShellStreamCallbacks {
onStderr(data: string): void;
}
/**
* A modern Pi exec frame plus the call id the dispatcher minted for it.
*
* Unlike the legacy exec args (`ReadArgs`, `ShellArgs`, ...), the Pi frames
* carry no `tool_call_id` field: on modern builds the id rides the streamed
* `ToolCall` envelope (`ToolCall.tool_call_id = 57`) instead of each variant's
* args. The exec channel has no access to that envelope, so the dispatcher
* mints an id and hands it to the handler, keeping the synthesized transcript
* block and its paired `toolResult` on the same key.
*/
export interface CursorPiCall<TArgs> {
args: TArgs;
toolCallId: string;
}
export interface CursorExecHandlers {
read?: (args: ReadArgs) => Promise<CursorExecHandlerResult<ReadResult>>;
ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
@@ -1002,6 +1031,18 @@ export interface CursorExecHandlers {
) => Promise<CursorExecHandlerResult<ShellResult>>;
diagnostics?: (args: DiagnosticsArgs) => Promise<CursorExecHandlerResult<DiagnosticsResult>>;
mcp?: (call: CursorMcpCall) => Promise<CursorExecHandlerResult<McpResult>>;
/**
* Modern Cursor CLI Pi tool frames (`ExecServerMessage` 45-51). They are a
* distinct frame family from the legacy `readArgs`/`shellArgs`/... set, not
* an alias: different args, different result oneofs, and no `tool_call_id`.
*/
piRead?: (call: CursorPiCall<PiReadExecArgs>) => Promise<CursorExecHandlerResult<PiReadExecResult>>;
piBash?: (call: CursorPiCall<PiBashExecArgs>) => Promise<CursorExecHandlerResult<PiBashExecResult>>;
piEdit?: (call: CursorPiCall<PiEditExecArgs>) => Promise<CursorExecHandlerResult<PiEditExecResult>>;
piWrite?: (call: CursorPiCall<PiWriteExecArgs>) => Promise<CursorExecHandlerResult<PiWriteExecResult>>;
piGrep?: (call: CursorPiCall<PiGrepExecArgs>) => Promise<CursorExecHandlerResult<PiGrepExecResult>>;
piFind?: (call: CursorPiCall<PiFindExecArgs>) => Promise<CursorExecHandlerResult<PiFindExecResult>>;
piLs?: (call: CursorPiCall<PiLsExecArgs>) => Promise<CursorExecHandlerResult<PiLsExecResult>>;
/** Mirror Cursor's server-owned todo list into local session state. */
todoSync?: CursorTodoSyncHandler;
onToolResult?: CursorToolResultHandler;
+12
View File
@@ -25,6 +25,18 @@ export const kStreamingBlockIndex = Symbol("provider.block.index");
/** Stores the last parsed argument prefix length for throttled streaming JSON parsing. */
export const kStreamingLastParseLen = Symbol("provider.block.lastParseLen");
/**
* The Cursor interaction envelope's `call_id` for a streamed tool-call block.
*
* Tracked separately from the block's own `id` because they are NOT the same
* key: MCP and Pi blocks are filed under the id inside the call's `args`, which
* is what the exec channel pairs its result under, while every streamed
* `ToolCall*Update` correlates on the envelope's `call_id`. Matching
* completions against the block id would mis-route every call whose args carry
* their own id.
*/
export const kStreamingEnvelopeId = Symbol("provider.block.envelopeId");
/** Marks streamed tool-call arguments that already received an authoritative done payload. */
export const kStreamingArgumentsDone = Symbol("provider.block.argumentsDone");
@@ -689,6 +689,7 @@ function newBlockState(): BlockState {
get currentToolCall() {
return toolCall;
},
openToolCalls: new Map(),
resolvedMcpToolCallIds: new Set(),
firstTokenTime: undefined,
setTextBlock: b => {
File diff suppressed because it is too large Load Diff
@@ -58,6 +58,7 @@ function newHarness(): Harness {
get currentToolCall() {
return toolCall;
},
openToolCalls: new Map(),
resolvedMcpToolCallIds: new Set(),
firstTokenTime: undefined,
setTextBlock: b => {
@@ -89,6 +89,7 @@ function newHarness(): Harness {
get currentToolCall() {
return toolCall;
},
openToolCalls: new Map(),
resolvedMcpToolCallIds: new Set(),
firstTokenTime: undefined,
setTextBlock: b => {
+3
View File
@@ -39,6 +39,9 @@
- Fixed Kimi Code (`kimi-code`) reporting `maxTokens: 32000` for every model — its `/coding/v1/models` discovery mapper and the bundled catalog applied a blanket constant, truncating `k3`/`k3-256k` output at ~4x below their real 131072 ceiling and `kimi-for-coding`/`kimi-for-coding-highspeed` below their 32768 ceiling. Output caps are now derived per family, and the model cache is invalidated so upgrades drop the stale `maxTokens: 32000` rows (including the discovery-only `k3-256k`) instead of serving them until the next network refresh ([#6711](https://github.com/can1357/oh-my-pi/issues/6711)).
- Fixed Anthropic model discovery 404ing when the registry derived the provider base URL from a bundled model without the `/v1` suffix (`https://api.anthropic.com/models` instead of `/v1/models`), which let a stale text-only cache row shadow fresh models.dev vision metadata — surfacing as snapcompact refusing to run on `claude-opus-5`. Discovery now always targets `/v1/models` while model rows keep the provider base URL ([#6563](https://github.com/can1357/oh-my-pi/issues/6563)).
### Added
- Regenerated the Cursor agent protobufs (`discovery/cursor-gen/agent_pb.ts`) against the modern `agent.proto`, adding the message and enum families current Cursor CLI builds emit: Pi tool exec frames, hook queries and responses, subagents, allowlist prechecks, MCP state, smart-mode classification, canvas diagnostics, conversation search, agent-store conflicts and git diff. Purely additive — no existing exported symbol changed shape.
## [17.1.4] - 2026-07-26
File diff suppressed because one or more lines are too long
+3
View File
@@ -161,6 +161,9 @@
- Fixed MiMo models using hashline edit mode by default despite needing the same replace-mode fallback as Kimi. ([#3772](https://github.com/can1357/oh-my-pi/issues/3772))
- Fixed `omp` refusing to start on Windows when no `bash.exe` is discoverable — most visibly with scoop-installed Git, whose manifest shims `sh.exe`/`git.exe` but never `bash.exe`, so PATH lookup missed it. Startup threw `No bash shell found` while merely building the bash tool description, even though bash tool commands always execute in the embedded brush-core shell and need no host bash. Shell discovery now also checks `GIT_INSTALL_ROOT`, scoop and per-user Git for Windows install roots, and `sh.exe` on PATH, then falls back to `cmd.exe` for the spawn-only paths (interactive PTY, ACP client terminals) instead of failing; the cmd fallback is never used to wrap user-shell commands — brush runs the POSIX line directly.
- Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)).
### Added
- The Cursor exec bridge serves the seven modern Pi tool frames, mapping each to its local equivalent: `pi_read`/`pi_ls` → `read`, `pi_bash` → `bash`, `pi_edit` → `edit`, `pi_write` → `write`, `pi_grep` → `grep`, and `pi_find` → `glob`. The frames are a separate wire family from the legacy args, not aliases, so each mapping is a real translation — `pi_grep`'s `ignore_case` is the inverse of the local tool's case-sensitivity flag, `pi_find` searches filenames rather than contents, and `pi_edit`'s replacements are renamed to the local snake_case pairs.
## [17.1.4] - 2026-07-26
+69
View File
@@ -401,6 +401,75 @@ export class CursorExecHandlers implements ICursorExecHandlers {
});
return toolResultMessage;
}
/**
* Modern Cursor CLI Pi tool frames (`ExecServerMessage` 45-51).
*
* These are a separate frame family from the legacy `read`/`shell`/... set,
* not aliases: different args, different result oneofs, and no `tool_call_id`
* (the provider mints one and passes it in `call.toolCallId`). Each maps onto
* the local tool with matching semantics, so the same approval, sandboxing
* and event plumbing applies as for a model-issued call.
*/
async piRead(call: Parameters<NonNullable<ICursorExecHandlers["piRead"]>>[0]) {
return await executeTool(this.options, "read", call.toolCallId, { path: call.args.path });
}
async piBash(call: Parameters<NonNullable<ICursorExecHandlers["piBash"]>>[0]) {
const { timeout } = call.args;
return await executeTool(this.options, "bash", call.toolCallId, {
command: call.args.command,
timeout: timeout && timeout > 0 ? timeout : undefined,
});
}
/**
* `PiEditExecArgs` is the local `edit` tool's replace mode verbatim: a path
* plus `old_text`/`new_text` pairs. The tool's schema is snake_case, so the
* proto's camelCase accessors are mapped back on the way in.
*/
async piEdit(call: Parameters<NonNullable<ICursorExecHandlers["piEdit"]>>[0]) {
return await executeTool(this.options, "edit", call.toolCallId, {
path: call.args.path,
edits: call.args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
});
}
async piWrite(call: Parameters<NonNullable<ICursorExecHandlers["piWrite"]>>[0]) {
return await executeTool(this.options, "write", call.toolCallId, {
path: call.args.path,
content: call.args.content,
});
}
async piGrep(call: Parameters<NonNullable<ICursorExecHandlers["piGrep"]>>[0]) {
const { pattern, path, glob, ignoreCase } = call.args;
// Same arg mapping as the legacy `grep` handler: the local tool takes one
// path spec, and its `case` flag is case-SENSITIVITY, the inverse of the
// frame's `ignore_case`.
return await executeTool(this.options, "grep", call.toolCallId, {
pattern,
path: glob ? `${path || "."}/${glob}` : path || ".",
case: ignoreCase === true ? false : undefined,
});
}
/**
* `pi_find` is a filename search, which is the local `glob` tool — not
* `grep`. Its `pattern` is a glob, joined onto `path` because `glob` takes a
* single combined path spec.
*/
async piFind(call: Parameters<NonNullable<ICursorExecHandlers["piFind"]>>[0]) {
const { pattern, path, limit } = call.args;
return await executeTool(this.options, "glob", call.toolCallId, {
path: path ? `${path}/${pattern}` : pattern,
limit: limit && limit > 0 ? limit : undefined,
});
}
/** Redirected to `read`, which lists directories — same as the legacy `ls`. */
async piLs(call: Parameters<NonNullable<ICursorExecHandlers["piLs"]>>[0]) {
return await executeTool(this.options, "read", call.toolCallId, { path: call.args.path || "." });
}
/**
* Settle a completed native Cursor todo call, mirroring its list when the
@@ -244,6 +244,7 @@ function newBlockState(): BlockState {
return toolCall;
},
firstTokenTime: undefined,
openToolCalls: new Map<string, ToolCallState>(),
resolvedMcpToolCallIds: new Set<string>(),
setTextBlock: b => {
textBlock = b;
@@ -430,3 +431,96 @@ describe("CursorExecHandlers native delete gating (issue #5680)", () => {
expect(await Bun.file(movedTarget).exists()).toBe(false);
});
});
// The Pi frames (`ExecServerMessage` 45-51) are a separate wire family from the
// legacy `read`/`shell`/`grep` args, with different field names and different
// semantics. Each bridge handler therefore performs a real translation, and a
// wrong one silently searches the wrong thing instead of failing.
describe("CursorExecHandlers Pi frame translation", () => {
let cwd: string;
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-pi-test-"));
});
afterEach(async () => {
await removeWithRetries(cwd);
});
/** Captures the args one local tool was invoked with. */
function recordingHandlers(toolName: string): { handlers: CursorExecHandlers; calls: unknown[] } {
const calls: unknown[] = [];
const tool: AgentTool = {
name: toolName,
label: toolName,
description: "records its args",
parameters: type({}),
execute: async (_toolCallId: string, params: unknown) => {
calls.push(params);
return { content: [{ type: "text" as const, text: "ok" }] };
},
};
const handlers = new CursorExecHandlers({ cwd, tools: new Map([[toolName, tool]]) });
return { handlers, calls };
}
it("inverts pi_grep's ignore_case into the local tool's case-sensitivity flag", async () => {
// `ignore_case` and `case` are opposites. Passing the frame's value
// straight through would flip every search's matching.
const { handlers, calls } = recordingHandlers("grep");
await handlers.piGrep({ toolCallId: "c1", args: { pattern: "x", ignoreCase: true } } as never);
await handlers.piGrep({ toolCallId: "c2", args: { pattern: "x", ignoreCase: false } } as never);
expect(calls).toEqual([
{ pattern: "x", path: ".", case: false },
// Case-sensitive is the local default, so `false` maps to "unset",
// not to `case: true`.
{ pattern: "x", path: ".", case: undefined },
]);
});
it("folds pi_grep's separate glob onto the local tool's single path spec", async () => {
const { handlers, calls } = recordingHandlers("grep");
await handlers.piGrep({ toolCallId: "c1", args: { pattern: "x", path: "src", glob: "**/*.ts" } } as never);
await handlers.piGrep({ toolCallId: "c2", args: { pattern: "x", glob: "**/*.ts" } } as never);
expect((calls[0] as { path: string }).path).toBe("src/**/*.ts");
expect((calls[1] as { path: string }).path).toBe("./**/*.ts");
});
it("routes pi_find to glob, not grep, joining its pattern onto the path", async () => {
// `pi_find` searches filenames. Routing it to `grep` would search file
// contents for the glob text and return nothing.
const { handlers, calls } = recordingHandlers("glob");
await handlers.piFind({ toolCallId: "c1", args: { pattern: "*.ts", path: "src", limit: 10 } } as never);
await handlers.piFind({ toolCallId: "c2", args: { pattern: "*.ts", limit: 0 } } as never);
expect(calls).toEqual([
{ path: "src/*.ts", limit: 10 },
// A zero limit is protobuf's unset, not a request for zero results.
{ path: "*.ts", limit: undefined },
]);
});
it("renames pi_edit's camelCase replacements to the local tool's snake_case pairs", async () => {
const { handlers, calls } = recordingHandlers("edit");
await handlers.piEdit({
toolCallId: "c1",
args: { path: "a.ts", edits: [{ oldText: "before", newText: "after" }] },
} as never);
expect(calls[0]).toEqual({ path: "a.ts", edits: [{ old_text: "before", new_text: "after" }] });
});
it("lists directories for pi_ls through read, defaulting an empty path to cwd", async () => {
const { handlers, calls } = recordingHandlers("read");
await handlers.piLs({ toolCallId: "c1", args: { path: "" } } as never);
expect(calls[0]).toEqual({ path: "." });
});
});