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:
committed by
can1357
parent
cdbe7c4ed2
commit
b6e01c8a3c
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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: "." });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user