Skip to content
Draft
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- **Structured logging via `client.app.log()` instead of raw `console.*`.** The
plugin's own diagnostics (transport fallback warnings, per-turn debug traces
gated on `OPENCODE_CURSOR_DEBUG=1`) now route through opencode's plugin
logging API (`service: "opencode-cursor"`) rather than `console.warn`/
`console.error`. Falls back to `console.*` when no client is available
(e.g. running the provider standalone).
- **Cursor SDK's own "rules"/"skills" load diagnostics captured and forwarded.**
`@cursor/sdk`'s bundled local-exec runtime writes internal messages like
`LocalCursorRulesService load completed meta={durationMs, ruleCount}` and
`AgentSkillsCursorRulesService load completed meta={durationMs, ruleCount,
skillCount}` straight to `console.log`, with no public logger hook to
redirect it. These are now recognized (in-process transport via a narrowly
scoped `console.log` interceptor; sidecar transport via the child process's
own interceptor forwarding over the existing JSONL protocol) and re-emitted
as structured opencode logs instead of raw terminal noise. Every other
`console.log` call passes through unchanged.

## [0.6.2] — 2026-07-28

Version-check UX cleanup from #79.
Expand Down
7 changes: 6 additions & 1 deletion src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { buildCursorTools } from "./cursor-tools.js";
import { getLocalVersion, getLatestVersion, clearVersionCache, PLUGIN_CACHE_PATH } from "../version-check.js";
import { removeSystemRule } from "../provider/system-rule.js";
import { clearLogBridge, setLogBridge } from "../provider/log-bridge.js";
import {
clearSubagentBridge,
setSubagentBridge,
Expand Down Expand Up @@ -103,7 +104,10 @@ export const CursorPlugin: Plugin = async (input) => {
// create a real child session for each Cursor subagent (making its `task`
// card clickable / `ctrl+x`-navigable). Same-process handoff via a globalThis
// registry; the provider degrades gracefully when it's absent.
if (client) setSubagentBridge({ client, directory });
if (client) {
setSubagentBridge({ client, directory });
setLogBridge({ client, directory });
}
// Canonical working directory for the generated system-prompt rule: the
// provider writes `.cursor/rules/opencode.mdc` under this path and dispose
// cleans it up from the same path. The config hook threads it into the
Expand Down Expand Up @@ -390,6 +394,7 @@ export const CursorPlugin: Plugin = async (input) => {
// so a user-owned opencode.mdc is never deleted.
removeSystemRule(resolvedCwd);
clearSubagentBridge();
clearLogBridge();
},
};
};
Expand Down
27 changes: 19 additions & 8 deletions src/provider/agent-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { loadCursorSdk } from "../cursor-runtime.js";
import { installCursorLogInterceptor } from "./cursor-log-intercept.js";
import { pluginLog } from "./log-bridge.js";
import { SidecarClient, type AgentLike } from "./sidecar-client.js";

export type { AgentLike, AgentRunLike, AgentSendOptions } from "./sidecar-client.js";
Expand Down Expand Up @@ -109,6 +111,11 @@ async function ensureHttp1Configured(): Promise<void> {
}

function inProcessBackend(useHttp1: boolean): AgentBackend {
// The SDK runs in this process and writes its own diagnostics straight to
// the shared global `console` (see cursor-log-intercept.ts); install once
// so its rules/skills load-completion logs route through opencode logging
// instead of appearing as raw stdout noise.
installCursorLogInterceptor();
return {
kind: "in-process",
createAgent: async (options) => {
Expand Down Expand Up @@ -143,7 +150,11 @@ export function resolveSidecarScript(): string | undefined {
}

function sidecarBackend(nodePath: string, scriptPath: string): AgentBackend {
const client = new SidecarClient({ scriptPath, nodePath });
const client = new SidecarClient({
scriptPath,
nodePath,
onLog: (level, message, meta) => pluginLog(level, message, meta),
});
return {
kind: "sidecar",
createAgent: (options) => client.createAgent(options),
Expand All @@ -161,18 +172,18 @@ export function loadAgentBackend(): AgentBackend {
const scriptPath = transport === "sidecar" ? resolveSidecarScript() : undefined;
if (transport === "sidecar" && (!env.nodePath || !scriptPath)) {
// Explicit sidecar request we can't satisfy: fall back loudly.
console.error(
"[opencode-cursor] Node sidecar requested but unavailable " +
`(node: ${env.nodePath ?? "not found"}, script: ${scriptPath ?? "not found"}); ` +
"falling back to in-process HTTP/1.1 transport.",
pluginLog(
"warn",
"Node sidecar requested but unavailable; falling back to in-process HTTP/1.1 transport.",
{ node: env.nodePath ?? null, script: scriptPath ?? null },
);
cached = inProcessBackend(true);
return cached;
}
if (transport === "http2-direct" && env.isBun) {
console.error(
"[opencode-cursor] http2-direct under Bun: Cursor streams may fail " +
"(Bun node:http2 incompatibility, oven-sh/bun#31499). " +
pluginLog(
"warn",
"http2-direct under Bun: Cursor streams may fail (Bun node:http2 incompatibility, oven-sh/bun#31499). " +
"Set OPENCODE_CURSOR_TRANSPORT=http1 (recommended) or sidecar.",
);
}
Expand Down
25 changes: 16 additions & 9 deletions src/provider/agent-events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AgentModeOption, SDKUserMessage } from "@cursor/sdk";
import type { AgentLike, AgentRunLike, AgentSendOptions } from "./agent-backend.js";
import { classifyError } from "./error-classify.js";
import { pluginLog } from "./log-bridge.js";

/** Token usage as reported by Cursor's `turn-ended` update. */
export interface CursorUsage {
Expand Down Expand Up @@ -202,9 +203,11 @@ export async function* streamAgentTurn(
if (options.abortSignal?.aborted) void Promise.resolve(run.cancel()).catch(() => {});
const result = await run.wait();
if (debug) {
console.error(
`[cursor:debug] updates=${JSON.stringify(counts)} status=${result.status} resultLen=${(result.result ?? "").length}`,
);
pluginLog("debug", "turn finished", {
updates: counts,
status: result.status,
resultLen: (result.result ?? "").length,
});
}
// Superseded by a watchdog force-resend: this run is abandoned.
if (gen !== runGen || finished) return;
Expand All @@ -221,7 +224,11 @@ export async function* streamAgentTurn(
.catch((err) => {
if (gen !== runGen) return;
failure = err;
if (debug) console.error(`[cursor:debug] send failed: ${err instanceof Error ? err.message : String(err)}`);
if (debug) {
pluginLog("debug", "send failed", {
error: err instanceof Error ? err.message : String(err),
});
}
})
.finally(() => {
// Only the live run finishes the stream; a superseded (cancelled-for-
Expand Down Expand Up @@ -265,7 +272,7 @@ export async function* streamAgentTurn(
return;
}
forced = true;
if (debug) console.error("[cursor:debug] stream stalled; cancelling and resending with local.force");
if (debug) pluginLog("debug", "stream stalled; cancelling and resending with local.force");
try {
await runHolder.run?.cancel();
} catch {
Expand Down Expand Up @@ -324,17 +331,17 @@ export async function sendWithRecovery(
} catch (err) {
const classified = classifyError(err);
if (classified.kind === "agent-busy") {
if (debug) console.error("[cursor:debug] agent busy; retrying send with local.force");
if (debug) pluginLog("debug", "agent busy; retrying send with local.force");
return agent.send(message, { ...sendOptions, local: { force: true } });
}
if (
(classified.kind === "rate-limit" || classified.kind === "network") &&
attempt < RETRY_BACKOFF_MS.length
) {
if (debug)
console.error(
`[cursor:debug] ${classified.kind}; retrying send in ${RETRY_BACKOFF_MS[attempt]}ms`,
);
pluginLog("debug", `${classified.kind}; retrying send`, {
delayMs: RETRY_BACKOFF_MS[attempt],
});
await sleep(RETRY_BACKOFF_MS[attempt]!);
continue;
}
Expand Down
92 changes: 92 additions & 0 deletions src/provider/cursor-log-intercept.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { pluginLog } from "./log-bridge.js";

// eslint-disable-next-line no-control-regex
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;

function stripAnsi(input: string): string {
return input.replace(ANSI_PATTERN, "");
}

/**
* `@cursor/sdk`'s bundled local-exec runtime formats its "rules"/"skills"
* loading diagnostics (context logger `local-exec:cursor-rules`) into one
* preformatted string and writes it straight to `console.log` — there is no
* public logger hook to redirect it instead. Observed shapes (colors
* stripped):
*
* 16:05:53.036 INFO LocalCursorRulesService load completed meta={durationMs: 89, ruleCount: 1}
* 16:05:53.036 INFO AgentSkillsCursorRulesService load completed meta={durationMs: 86, ruleCount: 18, skillCount: 18}
* 16:05:53.036 INFO CursorPluginsAgentSkillsService load completed meta={durationMs: 12, ruleCount: 2, skillCount: 0}
*
* The context path (`ctx=...`) is only present in some builds/configs.
*/
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*$/;

/** Parses the `meta={key: value, ...}` tail into a plain numeric object. */
export function parseCursorLogMeta(raw: string): Record<string, number> {
const out: Record<string, number> = {};
for (const part of raw.split(",")) {
const [key, value] = part.split(":").map((s) => s.trim());
if (!key || value === undefined) continue;
const num = Number(value);
if (Number.isFinite(num)) out[key] = num;
}
return out;
}

export interface ParsedCursorRuleLog {
service: string;
meta: Record<string, number>;
}

/** Matches one line against the known Cursor rules/skills load-completion shape. */
export function parseCursorRuleLoadLine(line: string): ParsedCursorRuleLog | undefined {
const match = RULE_LOAD_PATTERN.exec(stripAnsi(line));
if (!match) return undefined;
const [, service, meta] = match;
if (!service) return undefined;
return { service, meta: parseCursorLogMeta(meta ?? "") };
}

let installed = false;
let original: typeof console.log | 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.
*
* Only relevant to the in-process transport, where the SDK runs inside this
* process and writes directly to the shared global `console`. The sidecar
* transport intercepts the same messages in the child process instead (see
* `src/sidecar/agent-host.mjs`) and forwards them over the JSONL protocol.
*
* Idempotent: safe to call on every agent creation.
*/
export function installCursorLogInterceptor(): void {
if (installed) return;
original = console.log.bind(console);
const passthrough = original;
console.log = (...args: unknown[]) => {
if (args.length === 1 && typeof args[0] === "string") {
const parsed = parseCursorRuleLoadLine(args[0]);
if (parsed) {
pluginLog("info", `${parsed.service} load completed`, parsed.meta);
return;
}
}
passthrough(...(args as Parameters<typeof console.log>));
};
installed = true;
}

/** Test hook. */
export function resetCursorLogInterceptor(): void {
if (original) console.log = original;
original = undefined;
installed = false;
}
26 changes: 13 additions & 13 deletions src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "./message-map.js";
import { extractSystemText, resolveSystemDelivery } from "./system-rule.js";
import { classifyError } from "./error-classify.js";
import { pluginLog } from "./log-bridge.js";
import {
addUsage,
sendAgentTurnSilently,
Expand Down Expand Up @@ -126,7 +127,7 @@ export class CursorLanguageModel implements LanguageModelV3 {
private warnOnce(message: string): void {
if (this.warned.has(message)) return;
this.warned.add(message);
console.warn(`[${this.provider}] ${message}`);
pluginLog("warn", message, { provider: this.provider });
}

private requireApiKey(): string {
Expand Down Expand Up @@ -159,9 +160,10 @@ export class CursorLanguageModel implements LanguageModelV3 {
providerOptions,
);
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
console.error(
`[cursor:debug] model=${this.modelId} selection=${JSON.stringify(modelSelection)}`,
);
pluginLog("debug", "model call", {
model: this.modelId,
selection: modelSelection,
});
}
const sessionID =
typeof providerOptions?.["sessionID"] === "string"
Expand Down Expand Up @@ -240,9 +242,7 @@ export class CursorLanguageModel implements LanguageModelV3 {
: classification.kind === "continuation-multi"
? `resume-multi:${multiNewUserCount}`
: `fresh:${classification.kind}`;
console.error(
`[cursor:debug] turn classification=${label} session=${sessionID}`,
);
pluginLog("debug", "turn classification", { label, session: sessionID });
}
}

Expand Down Expand Up @@ -448,8 +448,9 @@ export class CursorLanguageModel implements LanguageModelV3 {
!options.abortSignal?.aborted
) {
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
console.error(
"[cursor:debug] resumed turn failed before emitting; retrying with a fresh agent",
pluginLog(
"debug",
"resumed turn failed before emitting; retrying with a fresh agent",
);
}
acquired.release();
Expand All @@ -468,10 +469,9 @@ export class CursorLanguageModel implements LanguageModelV3 {
// Non-Error throw or pre-existing cause: the original resume
// failure can't ride along as `cause`, so log it instead of
// dropping it silently.
console.error(
"[cursor:debug] original resume failure (not attachable as cause):",
err,
);
pluginLog("debug", "original resume failure (not attachable as cause)", {
error: err instanceof Error ? err.message : String(err),
});
}
throw retryErr;
}
Expand Down
Loading