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
59 changes: 46 additions & 13 deletions src/provider/cursor-log-intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ function stripAnsi(input: string): string {
const RULE_LOAD_PATTERN =
/^\d{2}:\d{2}:\d{2}\.\d{3}\s+INFO\s+(LocalCursorRulesService|AgentSkillsCursorRulesService|CursorPluginsAgentSkillsService) load completed(?:\s+ctx=\S+)?\s+meta=\{([^}]*)\}\s*$/;

/**
* One-shot `console.warn` diagnostics emitted at `@cursor/sdk` module load.
* Currently exactly one known line (vendored tree-sitter natives missing,
* shell parsing degrades to `parsingFailed`) — matched by prefix so future
* SDK builds appending detail still get captured.
*/
const SDK_WARNING_PREFIXES = [
"shell-parser: tree-sitter natives are unavailable in this artifact",
];

function matchesKnownSdkWarning(line: string): boolean {
return SDK_WARNING_PREFIXES.some((prefix) => line.startsWith(prefix));
}

/** Parses the `meta={key: value, ...}` tail into a plain numeric object. */
export function parseCursorLogMeta(raw: string): Record<string, number> {
const out: Record<string, number> = {};
Expand All @@ -41,7 +55,9 @@ export interface ParsedCursorRuleLog {
}

/** Matches one line against the known Cursor rules/skills load-completion shape. */
export function parseCursorRuleLoadLine(line: string): ParsedCursorRuleLog | undefined {
export function parseCursorRuleLoadLine(
line: string,
): ParsedCursorRuleLog | undefined {
const match = RULE_LOAD_PATTERN.exec(stripAnsi(line));
if (!match) return undefined;
const [, service, meta] = match;
Expand All @@ -50,15 +66,18 @@ export function parseCursorRuleLoadLine(line: string): ParsedCursorRuleLog | und
}

let installed = false;
let original: typeof console.log | undefined;
let originalLog: typeof console.log | undefined;
let originalWarn: typeof console.warn | undefined;

/**
* Installs a narrowly-scoped `console.log` interceptor that recognizes only
* the known Cursor rules/skills "load completed" messages (see
* {@link parseCursorRuleLoadLine}) and re-emits them as structured opencode
* logs via {@link pluginLog}. Every other `console.log` call — including
* anything else the SDK or the host process writes — passes through
* unchanged.
* Installs narrowly-scoped `console.log`/`console.warn` interceptors. On
* `console.log`, recognizes only the known Cursor rules/skills "load
* completed" messages (see {@link parseCursorRuleLoadLine}) and re-emits
* them as structured opencode logs via {@link pluginLog}. On `console.warn`,
* recognizes known one-shot SDK load diagnostics (see
* {@link SDK_WARNING_PREFIXES}) and routes them the same way. Every other
* `console.log`/`console.warn` call — including anything else the SDK or the
* host process writes — passes through unchanged.
*
* Only relevant to the in-process transport, where the SDK runs inside this
* process and writes directly to the shared global `console`. The sidecar
Expand All @@ -69,8 +88,8 @@ let original: typeof console.log | undefined;
*/
export function installCursorLogInterceptor(): void {
if (installed) return;
original = console.log.bind(console);
const passthrough = original;
originalLog = console.log.bind(console);
const logPassthrough = originalLog;
console.log = (...args: unknown[]) => {
if (args.length === 1 && typeof args[0] === "string") {
const parsed = parseCursorRuleLoadLine(args[0]);
Expand All @@ -79,14 +98,28 @@ export function installCursorLogInterceptor(): void {
return;
}
}
passthrough(...(args as Parameters<typeof console.log>));
logPassthrough(...(args as Parameters<typeof console.log>));
};
originalWarn = console.warn.bind(console);
const warnPassthrough = originalWarn;
console.warn = (...args: unknown[]) => {
if (args.length === 1 && typeof args[0] === "string") {
const line = stripAnsi(args[0]);
if (matchesKnownSdkWarning(line)) {
pluginLog("warn", line);
return;
}
}
warnPassthrough(...(args as Parameters<typeof console.warn>));
};
installed = true;
}

/** Test hook. */
export function resetCursorLogInterceptor(): void {
if (original) console.log = original;
original = undefined;
if (originalLog) console.log = originalLog;
if (originalWarn) console.warn = originalWarn;
originalLog = undefined;
originalWarn = undefined;
installed = false;
}
40 changes: 32 additions & 8 deletions src/sidecar/agent-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ function serializeError(err) {
const out = { name: err.name, message: err.message };
for (const k of ["status", "code", "isRetryable", "helpUrl"]) {
const v = err[k];
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") out[k] = v;
if (
typeof v === "number" ||
typeof v === "string" ||
typeof v === "boolean"
)
out[k] = v;
}
return out;
}
Expand All @@ -42,16 +47,23 @@ function write(payload) {
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;

// `@cursor/sdk`'s bundled local-exec runtime writes its rules/skills
// load-completion diagnostics straight to `console.log` (no public logger
// hook exists to redirect it — see src/provider/cursor-log-intercept.ts,
// which applies the identical pattern for the in-process transport). This
// process's own JSONL protocol never uses console.log (only
// process.stdout.write via write() above), so console.log here is entirely
// free for the SDK's use: recognized lines are forwarded to the parent as a
// structured "log" event instead of being written as raw, unparseable text.
// load-completion diagnostics straight to `console.log`, and its shell-parser
// emits a one-shot "tree-sitter natives unavailable" diagnostic via
// `console.warn` (no public logger hook exists to redirect either — see
// src/provider/cursor-log-intercept.ts, which applies the identical pattern
// for the in-process transport). This process's own JSONL protocol never uses
// console.log/console.warn (only process.stdout.write via write() above), so
// they are entirely free for the SDK's use: recognized lines are forwarded
// to the parent as a structured "log" event instead of being written as raw,
// unparseable text.
const RULE_LOAD_PATTERN =
/^\d{2}:\d{2}:\d{2}\.\d{3}\s+INFO\s+(LocalCursorRulesService|AgentSkillsCursorRulesService|CursorPluginsAgentSkillsService) load completed(?:\s+ctx=\S+)?\s+meta=\{([^}]*)\}\s*$/;

// One-shot SDK load diagnostics recognized on console.warn (prefix-matched).
const SDK_WARNING_PREFIXES = [
"shell-parser: tree-sitter natives are unavailable in this artifact",
];

function parseLogMeta(raw) {
const out = {};
for (const part of raw.split(",")) {
Expand Down Expand Up @@ -81,6 +93,18 @@ console.log = (...args) => {
originalConsoleLog(...args);
};

const originalConsoleWarn = console.warn.bind(console);
console.warn = (...args) => {
if (args.length === 1 && typeof args[0] === "string") {
const line = args[0].replace(ANSI_PATTERN, "");
if (SDK_WARNING_PREFIXES.some((prefix) => line.startsWith(prefix))) {
write({ ev: "log", level: "warn", message: line });
return;
}
}
originalConsoleWarn(...args);
};

let sdkPromise;
function loadSdk() {
// OPENCODE_CURSOR_SDK_PATH lets tests substitute a fake SDK module.
Expand Down
36 changes: 34 additions & 2 deletions test/cursor-log-intercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ describe("parseCursorRuleLoadLine", () => {
});

it("returns undefined for unrelated log lines", () => {
expect(parseCursorRuleLoadLine("some unrelated cursor sdk output")).toBeUndefined();
expect(parseCursorRuleLoadLine("Plugins reload completed: 3 plugins loaded")).toBeUndefined();
expect(
parseCursorRuleLoadLine("some unrelated cursor sdk output"),
).toBeUndefined();
expect(
parseCursorRuleLoadLine("Plugins reload completed: 3 plugins loaded"),
).toBeUndefined();
});
});

Expand Down Expand Up @@ -86,6 +90,34 @@ describe("installCursorLogInterceptor", () => {
passthrough.mockRestore();
});

it("routes known SDK console.warn diagnostics through pluginLog instead of stderr", () => {
const log = vi.fn().mockResolvedValue(undefined);
setLogBridge({ client: { app: { log } } } as never);

const passthrough = vi.spyOn(console, "warn").mockImplementation(() => {});
installCursorLogInterceptor();

console.warn(
"shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed",
);
console.warn("unrelated warning");

expect(log).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith({
body: {
service: "opencode-cursor",
level: "warn",
message:
"shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed",
},
});

resetCursorLogInterceptor();
expect(passthrough).toHaveBeenCalledTimes(1);
expect(passthrough).toHaveBeenCalledWith("unrelated warning");
passthrough.mockRestore();
});

it("is idempotent across repeated installs", () => {
installCursorLogInterceptor();
const first = console.log;
Expand Down
14 changes: 13 additions & 1 deletion test/fixtures/fake-cursor-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
* src/sidecar/agent-host.mjs / src/provider/cursor-log-intercept.ts), plus
* one unrelated console.log line, to verify the sidecar's log interception
* forwards only the recognized lines and passes everything else through.
*
* `options.emitShellParserWarn` -> Agent.create/resume writes the shell-parser
* "tree-sitter natives unavailable" diagnostic to console.warn, as the real
* @cursor/sdk does on first shell parse, plus one unrelated console.warn.
*/

function makeAgent(agentId, options) {
Expand All @@ -26,6 +30,12 @@ function makeAgent(agentId, options) {
);
console.log("some unrelated cursor sdk output");
}
if (options?.emitShellParserWarn) {
console.warn(
"shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed",
);
console.warn("some unrelated cursor sdk warning");
}
return {
agentId,
model: options?.model,
Expand All @@ -45,7 +55,9 @@ function makeAgent(agentId, options) {
err.helpUrl = "https://example.com/rate-limits";
throw err;
}
sendOptions?.onDelta?.({ update: { type: "text-delta", text: `echo:${text}` } });
sendOptions?.onDelta?.({
update: { type: "text-delta", text: `echo:${text}` },
});
if (text === "hang") {
let resolveWait;
const waited = new Promise((resolve) => {
Expand Down
Loading