pi-provider-cursor-ask 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/README.zh-CN.md +87 -0
  5. package/UPSTREAM_CHANGELOG.md +368 -0
  6. package/UPSTREAM_SOURCE.md +23 -0
  7. package/dist/index.js +54 -0
  8. package/package.json +97 -0
  9. package/src/auth/cli-credentials.ts +275 -0
  10. package/src/auth/consent.ts +25 -0
  11. package/src/auth/index.ts +23 -0
  12. package/src/auth/oauth.ts +282 -0
  13. package/src/auth/refresh-guard.ts +93 -0
  14. package/src/client/bridge.ts +673 -0
  15. package/src/client/cursor-wire.ts +213 -0
  16. package/src/client/h2-unary.ts +142 -0
  17. package/src/client/index.ts +18 -0
  18. package/src/config/index.ts +69 -0
  19. package/src/diagnostics/diagnostics.ts +116 -0
  20. package/src/diagnostics/index.ts +1 -0
  21. package/src/extension/auth.ts +99 -0
  22. package/src/extension/commands.ts +163 -0
  23. package/src/extension/compaction-guard.ts +86 -0
  24. package/src/extension/debug-hooks.ts +359 -0
  25. package/src/extension/index.ts +8 -0
  26. package/src/extension/provider.ts +277 -0
  27. package/src/extension/quota-adapter.ts +175 -0
  28. package/src/extension/report-dashboard.ts +133 -0
  29. package/src/identity.ts +16 -0
  30. package/src/index.ts +186 -0
  31. package/src/models/ask-catalog.ts +384 -0
  32. package/src/models/catalog.json +1163 -0
  33. package/src/models/cost.ts +126 -0
  34. package/src/models/index.ts +6 -0
  35. package/src/models/limits.ts +36 -0
  36. package/src/models/parameterized.ts +416 -0
  37. package/src/models/processing.ts +313 -0
  38. package/src/proto/agent_pb.ts +14577 -0
  39. package/src/stream/bridge-session.ts +215 -0
  40. package/src/stream/client-transcript.ts +51 -0
  41. package/src/stream/config.ts +5 -0
  42. package/src/stream/context-normalize.ts +308 -0
  43. package/src/stream/context-usage.ts +168 -0
  44. package/src/stream/debug-log.ts +316 -0
  45. package/src/stream/drift.ts +122 -0
  46. package/src/stream/images.ts +201 -0
  47. package/src/stream/index.ts +68 -0
  48. package/src/stream/interaction-query.ts +369 -0
  49. package/src/stream/message-parsing.ts +402 -0
  50. package/src/stream/model-cache.ts +100 -0
  51. package/src/stream/model-discovery.ts +242 -0
  52. package/src/stream/model-routing.ts +100 -0
  53. package/src/stream/native-core.ts +2121 -0
  54. package/src/stream/pi-adapter.ts +414 -0
  55. package/src/stream/protocol.ts +63 -0
  56. package/src/stream/recovery.ts +494 -0
  57. package/src/stream/request-build.ts +668 -0
  58. package/src/stream/root-prompt.ts +184 -0
  59. package/src/stream/run-journal.ts +474 -0
  60. package/src/stream/run-usage.ts +107 -0
  61. package/src/stream/server-messages.ts +777 -0
  62. package/src/stream/session-state.ts +499 -0
  63. package/src/stream/stream-writer.ts +211 -0
  64. package/src/stream/thinking-filter.ts +63 -0
  65. package/src/stream/tool-schema.ts +185 -0
  66. package/src/stream/transport-errors.ts +150 -0
  67. package/src/stream/tuning.ts +250 -0
  68. package/src/stream/types.ts +330 -0
  69. package/src/types/enums.ts +103 -0
  70. package/src/types/index.ts +4 -0
  71. package/src/usage.ts +262 -0
  72. package/src/utils/cache-dir.ts +39 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/security.ts +68 -0
  75. package/src/utils/util.ts +43 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Command registrations for the standalone Cursor Ask provider.
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { getLastDiagnostics } from "../diagnostics/diagnostics.js";
7
+ import { formatDriftSummary, getDriftSignals, hasStrandingDrift } from "../stream/drift.js";
8
+ import { readCachedCatalog } from "../stream/model-cache.js";
9
+ import { getCacheDir } from "../utils/cache-dir.js";
10
+ import { getCursorAgentUrl, getCursorClientVersion } from "../stream/config.js";
11
+ import { resolveSystemCredentialPolicy } from "../auth/consent.js";
12
+ import { getLifecycleLogPath } from "../stream/debug-log.js";
13
+ import { redactSecrets } from "../utils/security.js";
14
+ import { formatCursorUsage, getCursorUsageSummary } from "../usage.js";
15
+ import { CURSOR_ASK_COMMAND, CURSOR_ASK_IDENTITY } from "../identity.js";
16
+ import { ProviderConstant, type CredentialSource } from "../types/enums.js";
17
+ import type { ProcessedModel } from "../models/processing.js";
18
+ import { showCursorReport } from "./report-dashboard.js";
19
+
20
+ export interface CursorCommandOptions {
21
+ getAccessToken: (options?: { forceRefresh?: boolean }) => Promise<string>;
22
+ getLastRegisteredModels: () => ProcessedModel[];
23
+ getCurrentTokenSource: () => CredentialSource;
24
+ }
25
+
26
+ type NotifyLevel = "info" | "warning" | "error";
27
+
28
+ type CommandCompletion = { value: string; label: string; description?: string };
29
+
30
+ const SUBCOMMANDS: CommandCompletion[] = [
31
+ { value: "usage", label: "usage", description: "Show Cursor plan quota" },
32
+ { value: "doctor", label: "doctor", description: "Show sanitized diagnostics" },
33
+ ];
34
+
35
+ export function formatCursorCommandHelp(): string {
36
+ return `Usage: ${CURSOR_ASK_COMMAND} <usage|doctor>`;
37
+ }
38
+
39
+ export function emitCursorCommandOutput(
40
+ ctx: Pick<ExtensionCommandContext, "hasUI" | "ui">,
41
+ text: string,
42
+ level: NotifyLevel = "info",
43
+ ): void {
44
+ if (ctx.hasUI) {
45
+ ctx.ui.notify(text, level);
46
+ return;
47
+ }
48
+ if (level === "error") console.error(text);
49
+ else console.log(text);
50
+ }
51
+
52
+ export function getCursorCommandCompletions(prefix: string): CommandCompletion[] | null {
53
+ const needle = prefix.trim().toLowerCase();
54
+ const items = SUBCOMMANDS.filter((item) => item.value.startsWith(needle));
55
+ return items.length > 0 ? items : null;
56
+ }
57
+
58
+ export function registerCursorCommands(pi: ExtensionAPI, options: CursorCommandOptions): void {
59
+ pi.registerCommand(CURSOR_ASK_IDENTITY.commandName, {
60
+ description: "Cursor usage and diagnostics",
61
+ getArgumentCompletions: getCursorCommandCompletions,
62
+ handler: async (args, ctx) => {
63
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
64
+ const subcommand = (tokens[0] ?? "").toLowerCase();
65
+
66
+ if (!subcommand || subcommand === "help" || subcommand === "?") {
67
+ emitCursorCommandOutput(ctx, formatCursorCommandHelp());
68
+ return;
69
+ }
70
+
71
+ if (subcommand === "usage") {
72
+ try {
73
+ await showCursorReport(
74
+ ctx,
75
+ "Cursor usage",
76
+ formatCursorUsage(await getCursorUsageSummary(options.getAccessToken)),
77
+ );
78
+ } catch (error) {
79
+ emitCursorCommandOutput(
80
+ ctx,
81
+ `Cursor usage unavailable: ${redactSecrets(
82
+ error instanceof Error ? error.message : String(error),
83
+ )}`,
84
+ "error",
85
+ );
86
+ }
87
+ return;
88
+ }
89
+
90
+ if (subcommand === "doctor") {
91
+ await showCursorReport(ctx, "Cursor doctor", formatCursorDoctorReport(options));
92
+ return;
93
+ }
94
+
95
+ emitCursorCommandOutput(
96
+ ctx,
97
+ `Unknown ${CURSOR_ASK_COMMAND} subcommand. ${formatCursorCommandHelp()}`,
98
+ "warning",
99
+ );
100
+ },
101
+ });
102
+ }
103
+
104
+ function formatCursorDoctorReport(options: CursorCommandOptions): string {
105
+ const d = getLastDiagnostics();
106
+ const driftSignals = getDriftSignals();
107
+ const cachedCatalog = readCachedCatalog();
108
+ const registered = options.getLastRegisteredModels();
109
+ const currentTokenSource = options.getCurrentTokenSource();
110
+
111
+ const lines = [
112
+ "Cursor doctor",
113
+ `provider=${ProviderConstant.ProviderId}`,
114
+ `agentUrl=${getCursorAgentUrl()}`,
115
+ `clientVersion=${d.clientVersion || getCursorClientVersion()}`,
116
+ `tokenSource=${d.tokenSource || currentTokenSource || "none"}`,
117
+ `systemCredentials=${d.systemCredentials || resolveSystemCredentialPolicy()}`,
118
+ `lastResolvedRuntimeModel=${d.resolvedRuntimeModel || "none"}`,
119
+ `availableModels=${d.availableModels || registered.length || "none"}`,
120
+ `catalogCache=${
121
+ cachedCatalog
122
+ ? `${cachedCatalog.rawModels.length}+${cachedCatalog.parameterizedModels.length} models, age ${Math.round(
123
+ (Date.now() - cachedCatalog.savedAt) / 1000,
124
+ )}s`
125
+ : "none(using bundled fallback)"
126
+ }`,
127
+ `catalogCacheDir=${getCacheDir() || "unavailable"}`,
128
+ `matchedModel=${d.matchedModelDebug || "none"}`,
129
+ `lastEndpoint=${d.endpoint || "none"}`,
130
+ `lastStatus=${d.status ?? "none"}`,
131
+ `lastRpc=${d.lastRpc || "none"}`,
132
+ `lastRecoverySkipReason=${d.lastRecoverySkipReason || "none"}`,
133
+ `lastStreamEvent=${d.lastStreamEvent || "none"}`,
134
+ `lastRequestSize=${d.lastRequestSize || "none"}`,
135
+ `lastDriftSignal=${d.lastDriftSignal || "none"}`,
136
+ `wireDrift=${formatDriftSummary() || "none"}`,
137
+ `wireDriftStranding=${hasStrandingDrift() ? "yes" : "no"}`,
138
+ `lastIdleTimeoutAt=${d.lastIdleTimeoutAt || "none"}`,
139
+ `lastIdleTimeoutMs=${d.lastIdleTimeoutMs ?? "none"}`,
140
+ `lastIdleAttempt=${d.lastIdleAttempt ?? "none"}`,
141
+ `streamIdleTimeoutMs=${process.env.PI_CURSOR_STREAM_IDLE_TIMEOUT_MS || "0(disabled)"}`,
142
+ `resumeIdleTimeoutMs=${process.env.PI_CURSOR_RESUME_IDLE_TIMEOUT_MS || "0(disabled)"}`,
143
+ `streamIdleMaxRetries=${process.env.PI_CURSOR_STREAM_IDLE_MAX_RETRIES || "0(disabled)"}`,
144
+ `h2IdleTimeoutMs=${process.env.PI_CURSOR_H2_IDLE_TIMEOUT_MS || "0(disabled)"}`,
145
+ `lifecycleLog=${getLifecycleLogPath()}`,
146
+ `lastError=${d.error ? redactSecrets(d.error) : "none"}`,
147
+ "transport=native-streamSimple",
148
+ "unaryTransport=in-process-h2",
149
+ "runtimeCli=not-used",
150
+ "proxyPath=removed",
151
+ `commands=${CURSOR_ASK_COMMAND} usage|doctor`,
152
+ "hint=On stalls check lifecycle log + lastStreamEvent; InteractionQuery hangs fixed in 1.2.2; re-login or PI_CURSOR_CLIENT_VERSION on wire errors",
153
+ ];
154
+ if (driftSignals.length > 0) {
155
+ lines.push("--- wire drift detail ---");
156
+ for (const s of driftSignals) {
157
+ lines.push(` ${s.kind}: ${s.detail} (x${s.count}, first ${s.firstSeenIso})`);
158
+ }
159
+ lines.push(" Cursor's agent schema may have moved. See proto/README.md to regenerate,");
160
+ lines.push(" or pin PI_CURSOR_CLIENT_VERSION to a build that matches.");
161
+ }
162
+ return lines.join("\n");
163
+ }
@@ -0,0 +1,86 @@
1
+ /** Correct Pi's successful-response silent-overflow heuristic for cumulative Cursor receipts. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
4
+ import { positiveContextTokens, type CursorAssistantMessage } from "../stream/context-usage.js";
5
+ import { lifecycleLog } from "../stream/debug-log.js";
6
+ import { ProviderConstant } from "../types/enums.js";
7
+
8
+ export function shouldSuppressCursorOverflow(input: {
9
+ reason?: string;
10
+ willRetry?: boolean;
11
+ model?: { provider: string; id: string; contextWindow: number };
12
+ lastAssistant?: AssistantMessage;
13
+ reserveTokens: number;
14
+ currentContextTokens?: number | null;
15
+ }): boolean {
16
+ const { model, lastAssistant: message } = input;
17
+ if (input.reason !== "overflow" || input.willRetry !== false) return false;
18
+ if (!model || model.provider !== "cursor" || !message) return false;
19
+ if (
20
+ message.provider !== model.provider ||
21
+ message.model !== model.id ||
22
+ message.api !== ProviderConstant.NativeApi
23
+ )
24
+ return false;
25
+ if (message.stopReason !== "stop" || message.errorMessage) return false;
26
+ const metadata = (message as CursorAssistantMessage).cursorUsage;
27
+ const observed = positiveContextTokens(metadata?.context?.tokens);
28
+ const current = positiveContextTokens(input.currentContextTokens);
29
+ const window = positiveContextTokens(model.contextWindow);
30
+ if (
31
+ metadata?.version !== 1 ||
32
+ metadata.context?.source !== "checkpoint" ||
33
+ observed === undefined ||
34
+ current === undefined ||
35
+ window === undefined
36
+ )
37
+ return false;
38
+ if (
39
+ message.usage.totalTokens !== observed ||
40
+ !Number.isFinite(input.reserveTokens) ||
41
+ input.reserveTokens < 0
42
+ )
43
+ return false;
44
+ const promptBill = message.usage.input + message.usage.cacheRead;
45
+ const threshold = window - input.reserveTokens;
46
+ // Leave actual/near overflow, unknown evidence, manual requests and error recovery to Pi.
47
+ // Also check current context so a later large tool/user message cannot be hidden by an old snapshot.
48
+ return (
49
+ Number.isFinite(promptBill) &&
50
+ promptBill > window &&
51
+ observed < threshold &&
52
+ current < threshold
53
+ );
54
+ }
55
+
56
+ export function registerCursorCompactionGuard(pi: ExtensionAPI): void {
57
+ pi.on("session_before_compact", (event, ctx) => {
58
+ let lastAssistant: AssistantMessage | undefined;
59
+ for (let i = event.branchEntries.length - 1; i >= 0; i--) {
60
+ const entry = event.branchEntries[i]!;
61
+ if (entry.type === "compaction") break;
62
+ if (entry.type === "message" && entry.message.role === "assistant") {
63
+ lastAssistant = entry.message;
64
+ break;
65
+ }
66
+ }
67
+ if (
68
+ !shouldSuppressCursorOverflow({
69
+ reason: event.reason,
70
+ willRetry: event.willRetry,
71
+ model: ctx.model,
72
+ lastAssistant,
73
+ reserveTokens: event.preparation.settings.reserveTokens,
74
+ currentContextTokens: ctx.getContextUsage()?.tokens,
75
+ })
76
+ )
77
+ return;
78
+ lifecycleLog("compaction_overflow_suppressed", {
79
+ modelId: ctx.model?.id,
80
+ contextTokens: lastAssistant!.usage.totalTokens,
81
+ promptBill: lastAssistant!.usage.input + lastAssistant!.usage.cacheRead,
82
+ contextWindow: ctx.model?.contextWindow,
83
+ });
84
+ return { cancel: true };
85
+ });
86
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Extension debug logging hooks and image payload extraction.
3
+ */
4
+
5
+ import { appendFile } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { join as pathJoin } from "node:path";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { getCacheDir } from "../utils/cache-dir.js";
10
+ import { cleanupSessionState } from "../stream/session-state.js";
11
+ import { setCursorNotifySink } from "../stream/debug-log.js";
12
+
13
+ let extensionDebugLogFilePath: string | undefined;
14
+
15
+ export function isExtensionDebugEnabled(): boolean {
16
+ const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
17
+ return Boolean(raw && raw !== "0" && raw !== "false" && raw !== "off");
18
+ }
19
+
20
+ export function getExtensionDebugLogFilePath(): string {
21
+ if (extensionDebugLogFilePath) return extensionDebugLogFilePath;
22
+ const configured = process.env.PI_CURSOR_PROVIDER_EXTENSION_DEBUG_FILE?.trim();
23
+ if (configured) {
24
+ extensionDebugLogFilePath = configured;
25
+ return extensionDebugLogFilePath;
26
+ }
27
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
28
+ extensionDebugLogFilePath = pathJoin(
29
+ getCacheDir() ?? process.cwd(),
30
+ `pi-cursor-provider-extension-debug-${stamp}-${process.pid}.log`,
31
+ );
32
+ return extensionDebugLogFilePath;
33
+ }
34
+
35
+ export function truncateDebugValue(value: string, max = 240): string {
36
+ return value.length > max
37
+ ? `${value.slice(0, max)}…<truncated ${value.length - max} chars>`
38
+ : value;
39
+ }
40
+
41
+ export function summarizeBase64ImageData(data: string): {
42
+ base64Length: number;
43
+ byteLength?: number;
44
+ sha256?: string;
45
+ } {
46
+ const summary: { base64Length: number; byteLength?: number; sha256?: string } = {
47
+ base64Length: data.length,
48
+ };
49
+ try {
50
+ const bytes = Buffer.from(data.replace(/\s/g, ""), "base64");
51
+ if (bytes.length > 0) {
52
+ summary.byteLength = bytes.length;
53
+ summary.sha256 = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
54
+ }
55
+ } catch {
56
+ // Invalid base64 — keep length-only summary.
57
+ }
58
+ return summary;
59
+ }
60
+
61
+ export function summarizeImageBlock(type: unknown, mimeType: unknown, data: unknown): unknown {
62
+ return {
63
+ type,
64
+ mimeType,
65
+ ...(typeof data === "string"
66
+ ? summarizeBase64ImageData(data)
67
+ : { data: `<redacted base64 ${String(data ?? "").length} chars>` }),
68
+ };
69
+ }
70
+
71
+ export function summarizeDataImageUrl(url: string): unknown {
72
+ const match = url.trim().match(/^data:([^;,]+)(?:;[^,]*)?;base64,(.*)$/is);
73
+ if (!match) {
74
+ return {
75
+ url: url.startsWith("data:image/")
76
+ ? `<redacted data image ${url.length} chars>`
77
+ : truncateDebugValue(url),
78
+ };
79
+ }
80
+ return {
81
+ mimeType: match[1]?.toLowerCase(),
82
+ ...summarizeBase64ImageData(match[2] ?? ""),
83
+ };
84
+ }
85
+
86
+ export function summarizeContent(content: unknown): unknown {
87
+ if (typeof content === "string") return truncateDebugValue(content);
88
+ if (!Array.isArray(content)) return content;
89
+ return content.map((block) => {
90
+ if (!block || typeof block !== "object") return block;
91
+ const typed = block as Record<string, unknown>;
92
+ switch (typed.type) {
93
+ case "text":
94
+ return { type: "text", text: truncateDebugValue(String(typed.text ?? "")) };
95
+ case "thinking":
96
+ return { type: "thinking", thinking: truncateDebugValue(String(typed.thinking ?? "")) };
97
+ case "toolCall":
98
+ return {
99
+ type: "toolCall",
100
+ id: typed.id,
101
+ name: typed.name,
102
+ arguments: typed.arguments,
103
+ };
104
+ case "image":
105
+ return summarizeImageBlock("image", typed.mimeType, typed.data);
106
+ case "image_url": {
107
+ const url = (typed.image_url as Record<string, unknown> | undefined)?.url;
108
+ const text = typeof url === "string" ? url : "";
109
+ return { type: "image_url", image_url: summarizeDataImageUrl(text) };
110
+ }
111
+ default:
112
+ return typed;
113
+ }
114
+ });
115
+ }
116
+
117
+ export function summarizeMessage(message: unknown): unknown {
118
+ if (!message || typeof message !== "object") return message;
119
+ const typed = message as Record<string, unknown>;
120
+ return {
121
+ role: typed.role,
122
+ stopReason: typed.stopReason,
123
+ toolCallId: typed.toolCallId,
124
+ toolName: typed.toolName,
125
+ isError: typed.isError,
126
+ errorMessage: typed.errorMessage,
127
+ content: summarizeContent(typed.content),
128
+ };
129
+ }
130
+
131
+ export function summarizeBranchTail(
132
+ ctx: {
133
+ sessionManager?: {
134
+ getBranch?: () => unknown[];
135
+ getLeafId?: () => string | null;
136
+ getSessionId?: () => string;
137
+ };
138
+ },
139
+ limit = 6,
140
+ ): unknown {
141
+ try {
142
+ const branch = ctx.sessionManager?.getBranch?.();
143
+ if (!Array.isArray(branch)) return undefined;
144
+ return {
145
+ sessionId: ctx.sessionManager?.getSessionId?.(),
146
+ leafId: ctx.sessionManager?.getLeafId?.(),
147
+ size: branch.length,
148
+ tail: branch.slice(-limit).map((entry) => {
149
+ if (!entry || typeof entry !== "object") return entry;
150
+ const typed = entry as Record<string, unknown>;
151
+ return {
152
+ type: typed.type,
153
+ id: typed.id,
154
+ parentId: typed.parentId,
155
+ customType: typed.customType,
156
+ message: summarizeMessage(typed.message),
157
+ };
158
+ }),
159
+ };
160
+ } catch (error) {
161
+ return { error: error instanceof Error ? error.message : String(error) };
162
+ }
163
+ }
164
+
165
+ export interface CursorToolResultImagePayload {
166
+ toolCallId: string;
167
+ images: Array<{ data: string; mimeType: string }>;
168
+ }
169
+
170
+ function payloadToolCallIds(payload: Record<string, unknown>): Set<string> {
171
+ const ids = new Set<string>();
172
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
173
+ for (const message of messages) {
174
+ if (!message || typeof message !== "object") continue;
175
+ const typed = message as Record<string, unknown>;
176
+ if (typed.role === "tool" && typeof typed.tool_call_id === "string" && typed.tool_call_id) {
177
+ ids.add(typed.tool_call_id);
178
+ }
179
+ }
180
+ return ids;
181
+ }
182
+
183
+ export function extractToolResultImagePayloads(
184
+ ctx: { sessionManager?: { getBranch?: () => unknown[] } },
185
+ payload: Record<string, unknown>,
186
+ ): CursorToolResultImagePayload[] {
187
+ const idsInPayload = payloadToolCallIds(payload);
188
+ if (idsInPayload.size === 0) return [];
189
+ const branch = ctx.sessionManager?.getBranch?.();
190
+ if (!Array.isArray(branch)) return [];
191
+
192
+ const byToolCallId = new Map<string, CursorToolResultImagePayload>();
193
+ for (const entry of branch) {
194
+ if (!entry || typeof entry !== "object") continue;
195
+ const message = (entry as Record<string, unknown>).message;
196
+ if (!message || typeof message !== "object") continue;
197
+ const typed = message as Record<string, unknown>;
198
+ const toolCallId = typeof typed.toolCallId === "string" ? typed.toolCallId : "";
199
+ if (typed.role !== "toolResult" || !toolCallId || !idsInPayload.has(toolCallId)) continue;
200
+ const content = Array.isArray(typed.content) ? typed.content : [];
201
+ const images = content.flatMap((block) => {
202
+ if (!block || typeof block !== "object") return [];
203
+ const image = block as Record<string, unknown>;
204
+ if (
205
+ image.type !== "image" ||
206
+ typeof image.data !== "string" ||
207
+ typeof image.mimeType !== "string"
208
+ ) {
209
+ return [];
210
+ }
211
+ return [{ data: image.data, mimeType: image.mimeType }];
212
+ });
213
+ if (images.length === 0) continue;
214
+ const existing = byToolCallId.get(toolCallId);
215
+ if (existing) {
216
+ existing.images.push(...images);
217
+ } else {
218
+ byToolCallId.set(toolCallId, { toolCallId, images });
219
+ }
220
+ }
221
+ return [...byToolCallId.values()];
222
+ }
223
+
224
+ export function debugExtensionLog(event: string, data?: Record<string, unknown>): void {
225
+ if (!isExtensionDebugEnabled()) return;
226
+ try {
227
+ const payload = JSON.stringify({
228
+ ts: new Date().toISOString(),
229
+ pid: process.pid,
230
+ scope: "extension",
231
+ event,
232
+ ...data,
233
+ });
234
+ appendFile(
235
+ getExtensionDebugLogFilePath(),
236
+ `${payload}\n`,
237
+ { encoding: "utf8", mode: 0o600 },
238
+ () => {},
239
+ );
240
+ } catch {
241
+ // Debug logging must never break extension hooks.
242
+ }
243
+ }
244
+
245
+ export function registerCursorNotifySink(pi: ExtensionAPI): void {
246
+ const capture = (_event: unknown, ctx: ExtensionContext) => {
247
+ if (!ctx.hasUI) {
248
+ setCursorNotifySink(undefined);
249
+ return;
250
+ }
251
+ setCursorNotifySink((message, level) => {
252
+ ctx.ui.notify(message, level);
253
+ });
254
+ };
255
+
256
+ pi.on("session_start", capture);
257
+ pi.on("before_agent_start", capture);
258
+ }
259
+
260
+ export function registerSessionLifecycleCleanup(pi: ExtensionAPI): void {
261
+ const cleanupCurrentSession = (_event: unknown, ctx: ExtensionContext) => {
262
+ debugExtensionLog("session.cleanup_hook", {
263
+ sessionId: ctx.sessionManager.getSessionId(),
264
+ leafId: ctx.sessionManager.getLeafId?.(),
265
+ });
266
+ cleanupSessionState(ctx.sessionManager.getSessionId());
267
+ };
268
+
269
+ pi.on("session_before_switch", cleanupCurrentSession);
270
+ pi.on("session_before_fork", cleanupCurrentSession);
271
+ pi.on("session_before_tree", cleanupCurrentSession);
272
+ pi.on("session_shutdown", cleanupCurrentSession);
273
+ }
274
+
275
+ export function registerExtensionDebugHooks(pi: ExtensionAPI): void {
276
+ if (!isExtensionDebugEnabled()) return;
277
+
278
+ pi.on("message_start", (event, ctx) => {
279
+ if (ctx.model?.provider !== "cursor") return;
280
+ debugExtensionLog("message.start", {
281
+ sessionId: ctx.sessionManager.getSessionId(),
282
+ leafId: ctx.sessionManager.getLeafId?.(),
283
+ model: ctx.model?.id,
284
+ message: summarizeMessage((event as { message?: unknown }).message),
285
+ });
286
+ });
287
+
288
+ pi.on("message_update", (event, ctx) => {
289
+ if (ctx.model?.provider !== "cursor") return;
290
+ const typedEvent = event as {
291
+ message?: unknown;
292
+ assistantMessageEvent?: Record<string, unknown>;
293
+ };
294
+ debugExtensionLog("message.update", {
295
+ sessionId: ctx.sessionManager.getSessionId(),
296
+ leafId: ctx.sessionManager.getLeafId?.(),
297
+ model: ctx.model?.id,
298
+ assistantMessageEvent: typedEvent.assistantMessageEvent
299
+ ? {
300
+ type: typedEvent.assistantMessageEvent.type,
301
+ delta: truncateDebugValue(
302
+ String(
303
+ (typedEvent.assistantMessageEvent as Record<string, unknown>).delta ??
304
+ (typedEvent.assistantMessageEvent as Record<string, unknown>).content ??
305
+ "",
306
+ ),
307
+ ),
308
+ }
309
+ : undefined,
310
+ message: summarizeMessage(typedEvent.message),
311
+ });
312
+ });
313
+
314
+ pi.on("message_end", (event, ctx) => {
315
+ if (ctx.model?.provider !== "cursor") return;
316
+ debugExtensionLog("message.end", {
317
+ sessionId: ctx.sessionManager.getSessionId(),
318
+ leafId: ctx.sessionManager.getLeafId?.(),
319
+ model: ctx.model?.id,
320
+ message: summarizeMessage((event as { message?: unknown }).message),
321
+ branch: summarizeBranchTail(ctx),
322
+ });
323
+ });
324
+
325
+ pi.on("context", (event, ctx) => {
326
+ if (ctx.model?.provider !== "cursor") return;
327
+ const typedEvent = event as { messages?: unknown[] };
328
+ debugExtensionLog("context", {
329
+ sessionId: ctx.sessionManager.getSessionId(),
330
+ leafId: ctx.sessionManager.getLeafId?.(),
331
+ model: ctx.model?.id,
332
+ messageCount: Array.isArray(typedEvent.messages) ? typedEvent.messages.length : undefined,
333
+ messages: Array.isArray(typedEvent.messages)
334
+ ? typedEvent.messages.slice(-8).map((message) => summarizeMessage(message))
335
+ : undefined,
336
+ branch: summarizeBranchTail(ctx),
337
+ });
338
+ });
339
+
340
+ pi.on("turn_end", (event, ctx) => {
341
+ if (ctx.model?.provider !== "cursor") return;
342
+ const typedEvent = event as { turnIndex?: number; message?: unknown; toolResults?: unknown[] };
343
+ debugExtensionLog("turn.end", {
344
+ sessionId: ctx.sessionManager.getSessionId(),
345
+ leafId: ctx.sessionManager.getLeafId?.(),
346
+ model: ctx.model?.id,
347
+ turnIndex: typedEvent.turnIndex,
348
+ message: summarizeMessage(typedEvent.message),
349
+ toolResults: Array.isArray(typedEvent.toolResults)
350
+ ? typedEvent.toolResults.map((message) => summarizeMessage(message))
351
+ : undefined,
352
+ branch: summarizeBranchTail(ctx),
353
+ });
354
+ });
355
+
356
+ debugExtensionLog("extension.debug_hooks_registered", {
357
+ logFile: getExtensionDebugLogFilePath(),
358
+ });
359
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Extension layer entry point and exports.
3
+ */
4
+
5
+ export * from "./auth.js";
6
+ export * from "./commands.js";
7
+ export * from "./debug-hooks.js";
8
+ export * from "./provider.js";