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,213 @@
1
+ export interface CursorModelParameter {
2
+ id: string;
3
+ value: string;
4
+ }
5
+
6
+ export interface CursorParameterizedVariant {
7
+ parameters: CursorModelParameter[];
8
+ isMaxMode: boolean;
9
+ isDefaultMaxConfig?: boolean;
10
+ isDefaultNonMaxConfig?: boolean;
11
+ displayName?: string;
12
+ displayNameOutsidePicker?: string;
13
+ variantStringRepresentation?: string;
14
+ }
15
+
16
+ export interface CursorParameterizedModel {
17
+ name: string;
18
+ clientDisplayName?: string;
19
+ serverModelName?: string;
20
+ supportsMaxMode?: boolean;
21
+ supportsNonMaxMode?: boolean;
22
+ supportsImages?: boolean;
23
+ contextTokenLimit?: number;
24
+ contextTokenLimitForMaxMode?: number;
25
+ variants: CursorParameterizedVariant[];
26
+ }
27
+
28
+ function encodeVarint(value: number): number[] {
29
+ const out: number[] = [];
30
+ let v = value >>> 0;
31
+ while (v >= 0x80) {
32
+ out.push((v & 0x7f) | 0x80);
33
+ v >>>= 7;
34
+ }
35
+ out.push(v);
36
+ return out;
37
+ }
38
+
39
+ function encodeBoolField(fieldNo: number, value: boolean): number[] {
40
+ return [...encodeVarint(fieldNo << 3), value ? 1 : 0];
41
+ }
42
+
43
+ export function encodeAvailableModelsRequest(): Uint8Array {
44
+ // aiserver.v1.AvailableModelsRequest {
45
+ // optional bool use_model_parameters = 5;
46
+ // optional bool do_not_use_markdown = 7;
47
+ // }
48
+ return new Uint8Array([...encodeBoolField(5, true), ...encodeBoolField(7, true)]);
49
+ }
50
+
51
+ interface WireReader {
52
+ bytes: Uint8Array;
53
+ offset: number;
54
+ }
55
+
56
+ function readVarint(reader: WireReader): number {
57
+ let result = 0;
58
+ let shift = 0;
59
+ while (reader.offset < reader.bytes.length) {
60
+ const byte = reader.bytes[reader.offset++]!;
61
+ // Avoid JS bitwise operators here: they truncate to 32 bits, but protobuf
62
+ // varints can legally carry 64-bit values on fields we merely skip.
63
+ if (shift < 53) result += (byte & 0x7f) * 2 ** shift;
64
+ if ((byte & 0x80) === 0) return result;
65
+ shift += 7;
66
+ if (shift >= 70) throw new Error("varint too long");
67
+ }
68
+ throw new Error("unexpected EOF while reading varint");
69
+ }
70
+
71
+ function readLengthDelimited(reader: WireReader): Uint8Array {
72
+ const length = readVarint(reader);
73
+ if (!Number.isSafeInteger(length)) throw new Error("length-delimited size is too large");
74
+ const end = reader.offset + length;
75
+ if (end > reader.bytes.length) throw new Error("length-delimited field exceeds buffer");
76
+ const value = reader.bytes.subarray(reader.offset, end);
77
+ reader.offset = end;
78
+ return value;
79
+ }
80
+
81
+ function skipBytes(reader: WireReader, length: number): void {
82
+ const end = reader.offset + length;
83
+ if (end > reader.bytes.length) throw new Error("fixed-width field exceeds buffer");
84
+ reader.offset = end;
85
+ }
86
+
87
+ function skipWireField(reader: WireReader, wireType: number): void {
88
+ switch (wireType) {
89
+ case 0:
90
+ readVarint(reader);
91
+ return;
92
+ case 1:
93
+ skipBytes(reader, 8);
94
+ return;
95
+ case 2:
96
+ readLengthDelimited(reader);
97
+ return;
98
+ case 5:
99
+ skipBytes(reader, 4);
100
+ return;
101
+ default:
102
+ throw new Error(`unsupported wire type ${wireType}`);
103
+ }
104
+ }
105
+
106
+ function decodeString(bytes: Uint8Array): string {
107
+ return new TextDecoder().decode(bytes);
108
+ }
109
+
110
+ function decodeModelParameter(bytes: Uint8Array): CursorModelParameter {
111
+ const reader: WireReader = { bytes, offset: 0 };
112
+ const parameter: CursorModelParameter = { id: "", value: "" };
113
+ while (reader.offset < bytes.length) {
114
+ const tag = readVarint(reader);
115
+ const fieldNo = tag >>> 3;
116
+ const wireType = tag & 0x7;
117
+ if (fieldNo === 1 && wireType === 2) parameter.id = decodeString(readLengthDelimited(reader));
118
+ else if (fieldNo === 2 && wireType === 2)
119
+ parameter.value = decodeString(readLengthDelimited(reader));
120
+ else skipWireField(reader, wireType);
121
+ }
122
+ return parameter;
123
+ }
124
+
125
+ function decodeParameterizedVariant(bytes: Uint8Array): CursorParameterizedVariant {
126
+ const reader: WireReader = { bytes, offset: 0 };
127
+ const variant: CursorParameterizedVariant = { parameters: [], isMaxMode: false };
128
+ while (reader.offset < bytes.length) {
129
+ const tag = readVarint(reader);
130
+ const fieldNo = tag >>> 3;
131
+ const wireType = tag & 0x7;
132
+ if (fieldNo === 1 && wireType === 2)
133
+ variant.parameters.push(decodeModelParameter(readLengthDelimited(reader)));
134
+ else if (fieldNo === 2 && wireType === 2)
135
+ variant.displayName = decodeString(readLengthDelimited(reader));
136
+ else if (fieldNo === 8 && wireType === 2)
137
+ variant.displayNameOutsidePicker = decodeString(readLengthDelimited(reader));
138
+ else if (fieldNo === 3 && wireType === 0) variant.isMaxMode = readVarint(reader) !== 0;
139
+ else if (fieldNo === 4 && wireType === 0) variant.isDefaultMaxConfig = readVarint(reader) !== 0;
140
+ else if (fieldNo === 5 && wireType === 0)
141
+ variant.isDefaultNonMaxConfig = readVarint(reader) !== 0;
142
+ else if (fieldNo === 9 && wireType === 2)
143
+ variant.variantStringRepresentation = decodeString(readLengthDelimited(reader));
144
+ else skipWireField(reader, wireType);
145
+ }
146
+ return variant;
147
+ }
148
+
149
+ function decodeParameterizedModel(bytes: Uint8Array): CursorParameterizedModel {
150
+ const reader: WireReader = { bytes, offset: 0 };
151
+ const model: CursorParameterizedModel = { name: "", variants: [] };
152
+ while (reader.offset < bytes.length) {
153
+ const tag = readVarint(reader);
154
+ const fieldNo = tag >>> 3;
155
+ const wireType = tag & 0x7;
156
+ if (fieldNo === 1 && wireType === 2) model.name = decodeString(readLengthDelimited(reader));
157
+ else if (fieldNo === 10 && wireType === 0) model.supportsImages = readVarint(reader) !== 0;
158
+ else if (fieldNo === 14 && wireType === 0) model.supportsMaxMode = readVarint(reader) !== 0;
159
+ else if (fieldNo === 19 && wireType === 0) model.supportsNonMaxMode = readVarint(reader) !== 0;
160
+ else if (fieldNo === 15 && wireType === 0) model.contextTokenLimit = readVarint(reader);
161
+ else if (fieldNo === 16 && wireType === 0)
162
+ model.contextTokenLimitForMaxMode = readVarint(reader);
163
+ else if (fieldNo === 17 && wireType === 2)
164
+ model.clientDisplayName = decodeString(readLengthDelimited(reader));
165
+ else if (fieldNo === 18 && wireType === 2)
166
+ model.serverModelName = decodeString(readLengthDelimited(reader));
167
+ else if (fieldNo === 30 && wireType === 2)
168
+ model.variants.push(decodeParameterizedVariant(readLengthDelimited(reader)));
169
+ else skipWireField(reader, wireType);
170
+ }
171
+ return model;
172
+ }
173
+
174
+ export function decodeAvailableModelsResponse(bytes: Uint8Array): CursorParameterizedModel[] {
175
+ const reader: WireReader = { bytes, offset: 0 };
176
+ const models: CursorParameterizedModel[] = [];
177
+ while (reader.offset < bytes.length) {
178
+ const tag = readVarint(reader);
179
+ const fieldNo = tag >>> 3;
180
+ const wireType = tag & 0x7;
181
+ if (fieldNo === 2 && wireType === 2) {
182
+ const model = decodeParameterizedModel(readLengthDelimited(reader));
183
+ if (model.name) models.push(model);
184
+ } else {
185
+ skipWireField(reader, wireType);
186
+ }
187
+ }
188
+ return models;
189
+ }
190
+
191
+ // No generated schema for selectedContextBlob; emit raw wire format for the two
192
+ // fields Cursor actually reads: field 1 (repeated bytes) rootPromptMessagesJson
193
+ // refs, field 22 (string) clientName. Lengths are varint-encoded so this stays
194
+ // correct even if a caller ever passes a value >=128 bytes.
195
+ export function buildSelectedContextBlob(
196
+ rootPromptBlobIds: Uint8Array[],
197
+ clientName: string,
198
+ ): Uint8Array {
199
+ const parts: Uint8Array[] = [];
200
+ for (const blobId of rootPromptBlobIds) {
201
+ parts.push(new Uint8Array([0x0a, ...encodeVarint(blobId.length), ...blobId]));
202
+ }
203
+ const clientBytes = new TextEncoder().encode(clientName);
204
+ parts.push(new Uint8Array([0xb2, 0x01, ...encodeVarint(clientBytes.length), ...clientBytes]));
205
+ const total = parts.reduce((n, p) => n + p.length, 0);
206
+ const result = new Uint8Array(total);
207
+ let offset = 0;
208
+ for (const p of parts) {
209
+ result.set(p, offset);
210
+ offset += p.length;
211
+ }
212
+ return result;
213
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Node-only in-process HTTP/2 transport for Cursor unary Connect RPCs.
3
+ *
4
+ * `fetch` is not an option: Cursor's agent hosts speak HTTP/2 and undici does
5
+ * not provide the required HTTP/2 client transport.
6
+ */
7
+ import http2 from "node:http2";
8
+ import { randomUUID } from "node:crypto";
9
+
10
+ import { getCursorClientVersion } from "../config/index.js";
11
+
12
+ const CURSOR_API_URL = "https://api2.cursor.sh";
13
+ export const MAX_UNARY_RESPONSE_BYTES = 16 * 1024 * 1024;
14
+
15
+ export interface UnaryH2Options {
16
+ accessToken: string;
17
+ rpcPath: string;
18
+ requestBody: Uint8Array;
19
+ url?: string;
20
+ timeoutMs?: number;
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ export interface UnaryH2Result {
25
+ status: number;
26
+ body: Buffer;
27
+ }
28
+
29
+ export class UnaryH2TimeoutError extends Error {
30
+ constructor(message: string) {
31
+ super(message);
32
+ this.name = "UnaryH2TimeoutError";
33
+ }
34
+ }
35
+
36
+ /** Perform one unary Connect RPC over an in-process Node HTTP/2 session. */
37
+ export function callUnaryOverH2(options: UnaryH2Options): Promise<UnaryH2Result> {
38
+ const origin = options.url ?? CURSOR_API_URL;
39
+ const timeoutMs = options.timeoutMs ?? 15_000;
40
+
41
+ return new Promise<UnaryH2Result>((resolve, reject) => {
42
+ let settled = false;
43
+ let session: http2.ClientHttp2Session | undefined;
44
+ let request: http2.ClientHttp2Stream | undefined;
45
+ let timer: NodeJS.Timeout | undefined;
46
+
47
+ const cleanup = () => {
48
+ if (timer) clearTimeout(timer);
49
+ options.signal?.removeEventListener("abort", onAbort);
50
+ try {
51
+ session?.close();
52
+ } catch {
53
+ // Session may already be torn down by the event that settled the RPC.
54
+ }
55
+ };
56
+
57
+ const fail = (error: Error) => {
58
+ if (settled) return;
59
+ settled = true;
60
+ cleanup();
61
+ try {
62
+ request?.close(http2.constants.NGHTTP2_CANCEL);
63
+ session?.destroy();
64
+ } catch {
65
+ // Destruction is best effort; the session is abandoned either way.
66
+ }
67
+ reject(error);
68
+ };
69
+
70
+ const succeed = (result: UnaryH2Result) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ cleanup();
74
+ resolve(result);
75
+ };
76
+
77
+ function onAbort() {
78
+ fail(new UnaryH2TimeoutError("Cursor unary RPC aborted"));
79
+ }
80
+
81
+ if (options.signal?.aborted) {
82
+ reject(new UnaryH2TimeoutError("Cursor unary RPC aborted"));
83
+ return;
84
+ }
85
+ options.signal?.addEventListener("abort", onAbort, { once: true });
86
+
87
+ if (timeoutMs > 0) {
88
+ timer = setTimeout(
89
+ () => fail(new UnaryH2TimeoutError(`Cursor unary RPC timed out after ${timeoutMs}ms`)),
90
+ timeoutMs,
91
+ );
92
+ timer.unref?.();
93
+ }
94
+
95
+ try {
96
+ session = http2.connect(origin);
97
+ } catch (error) {
98
+ fail(error instanceof Error ? error : new Error(String(error)));
99
+ return;
100
+ }
101
+
102
+ session.on("error", (error) => fail(error));
103
+
104
+ try {
105
+ request = session.request({
106
+ ":method": "POST",
107
+ ":path": options.rpcPath,
108
+ "content-type": "application/proto",
109
+ "connect-protocol-version": "1",
110
+ te: "trailers",
111
+ authorization: `Bearer ${options.accessToken}`,
112
+ "x-ghost-mode": "true",
113
+ "x-cursor-client-version": getCursorClientVersion(),
114
+ "x-cursor-client-type": "cli",
115
+ "x-request-id": randomUUID(),
116
+ });
117
+ } catch (error) {
118
+ fail(error instanceof Error ? error : new Error(String(error)));
119
+ return;
120
+ }
121
+
122
+ const chunks: Buffer[] = [];
123
+ let responseBytes = 0;
124
+ let status = 0;
125
+
126
+ request.on("response", (headers) => {
127
+ status = Number(headers[":status"] ?? 0);
128
+ });
129
+ request.on("data", (chunk: Buffer) => {
130
+ responseBytes += chunk.byteLength;
131
+ if (responseBytes > MAX_UNARY_RESPONSE_BYTES) {
132
+ fail(new Error(`Cursor unary response exceeds ${MAX_UNARY_RESPONSE_BYTES} bytes`));
133
+ return;
134
+ }
135
+ chunks.push(Buffer.from(chunk));
136
+ });
137
+ request.on("error", (error) => fail(error));
138
+ request.on("end", () => succeed({ status, body: Buffer.concat(chunks) }));
139
+
140
+ request.end(Buffer.from(options.requestBody));
141
+ });
142
+ }
@@ -0,0 +1,18 @@
1
+ export {
2
+ createBridge,
3
+ createConnectFrameParser,
4
+ frameConnectMessage,
5
+ parseConnectEndStream,
6
+ type BridgeHandle,
7
+ type BridgeFactory,
8
+ type CreateBridgeOptions,
9
+ } from "./bridge.js";
10
+ export {
11
+ encodeAvailableModelsRequest,
12
+ decodeAvailableModelsResponse,
13
+ buildSelectedContextBlob,
14
+ type CursorModelParameter,
15
+ type CursorParameterizedModel,
16
+ type CursorParameterizedVariant,
17
+ } from "./cursor-wire.js";
18
+ export { getCursorAgentUrl, getCursorClientVersion } from "../config/index.js";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Cursor agent URL and client version resolution (env → CLI config → default).
3
+ */
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join as pathJoin } from "node:path";
7
+ import { assertSafeCursorBaseUrl } from "../utils/security.js";
8
+
9
+ export const DEFAULT_CURSOR_AGENT_URL = "https://agentn.us.api5.cursor.sh";
10
+ export const DEFAULT_CURSOR_CLIENT_VERSION = "cli-2026.05.01-eea359f";
11
+
12
+ let cachedCursorAgentUrl: string | undefined;
13
+
14
+ export function normalizeCursorUrl(value: unknown): string | undefined {
15
+ if (typeof value !== "string") return undefined;
16
+ const trimmed = value.trim();
17
+ if (!trimmed) return undefined;
18
+ try {
19
+ const url = new URL(trimmed);
20
+ if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
21
+ url.pathname = url.pathname.replace(/\/+$/, "");
22
+ url.search = "";
23
+ url.hash = "";
24
+ return url.toString().replace(/\/$/, "");
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ export function readCursorCliAgentUrl(): string | undefined {
31
+ const configDir = process.env.CURSOR_CONFIG_DIR?.trim() || pathJoin(homedir(), ".cursor");
32
+ try {
33
+ const config = JSON.parse(readFileSync(pathJoin(configDir, "cli-config.json"), "utf8")) as {
34
+ serverConfigCache?: {
35
+ agentUrlConfig?: { agentnUrl?: unknown; agentUrl?: unknown };
36
+ };
37
+ };
38
+ return (
39
+ normalizeCursorUrl(config.serverConfigCache?.agentUrlConfig?.agentnUrl) ??
40
+ normalizeCursorUrl(config.serverConfigCache?.agentUrlConfig?.agentUrl)
41
+ );
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ export function getCursorClientVersion(): string {
48
+ return process.env.PI_CURSOR_CLIENT_VERSION?.trim() || DEFAULT_CURSOR_CLIENT_VERSION;
49
+ }
50
+
51
+ /** Resolve the agent base URL, validating host against the allowlist. */
52
+ export function getCursorAgentUrl(): string {
53
+ const envUrl =
54
+ normalizeCursorUrl(process.env.PI_CURSOR_AGENT_URL) ??
55
+ normalizeCursorUrl(process.env.CURSOR_AGENT_URL);
56
+ if (envUrl) {
57
+ cachedCursorAgentUrl = assertSafeCursorBaseUrl(envUrl);
58
+ return cachedCursorAgentUrl;
59
+ }
60
+ if (cachedCursorAgentUrl) return cachedCursorAgentUrl;
61
+ const resolved = readCursorCliAgentUrl() ?? DEFAULT_CURSOR_AGENT_URL;
62
+ cachedCursorAgentUrl = assertSafeCursorBaseUrl(resolved);
63
+ return cachedCursorAgentUrl;
64
+ }
65
+
66
+ /** Test helper: clear cached URL between cases. */
67
+ export function resetCursorAgentUrlCacheForTests(): void {
68
+ cachedCursorAgentUrl = undefined;
69
+ }
@@ -0,0 +1,116 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { redactSecrets } from "../utils/security.js";
3
+
4
+ export type DiagnosticsSnapshot = {
5
+ status?: number;
6
+ endpoint?: string;
7
+ error?: string;
8
+ projectId?: string;
9
+ resolvedRuntimeModel?: string;
10
+ availableModels?: string;
11
+ matchedModelDebug?: string;
12
+ lastRpc?: string;
13
+ tokenSource?: string;
14
+ clientVersion?: string;
15
+ lastRecoverySkipReason?: string;
16
+ systemCredentials?: string;
17
+ /** ISO timestamp of the most recent stream idle timeout. */
18
+ lastIdleTimeoutAt?: string;
19
+ /** Configured idle timeout (ms) that fired. */
20
+ lastIdleTimeoutMs?: number;
21
+ /** Attempt number when the idle timeout fired. */
22
+ lastIdleAttempt?: number;
23
+ /** Short event name for the latest stream lifecycle signal. */
24
+ lastStreamEvent?: string;
25
+ /** Most recent wire-protocol drift observation (`kind:detail`). */
26
+ lastDriftSignal?: string;
27
+ /** Compact request-size breakdown from the latest stream start. */
28
+ lastRequestSize?: string;
29
+ };
30
+
31
+ const storage = new AsyncLocalStorage<DiagnosticsSnapshot>();
32
+ let lastSnapshot: DiagnosticsSnapshot = {};
33
+
34
+ function currentBag(): DiagnosticsSnapshot {
35
+ return storage.getStore() ?? lastSnapshot;
36
+ }
37
+
38
+ export async function runWithDiagnostics<T>(fn: () => Promise<T>): Promise<T> {
39
+ const bag: DiagnosticsSnapshot = {};
40
+ return storage.run(bag, async () => {
41
+ try {
42
+ return await fn();
43
+ } finally {
44
+ lastSnapshot = { ...bag };
45
+ }
46
+ });
47
+ }
48
+
49
+ export function getLastDiagnostics(): Readonly<DiagnosticsSnapshot> {
50
+ return lastSnapshot;
51
+ }
52
+
53
+ export function setLastStatus(status: number | undefined): void {
54
+ currentBag().status = status;
55
+ }
56
+ export function setLastEndpoint(endpoint: string | undefined): void {
57
+ currentBag().endpoint = endpoint;
58
+ }
59
+ export function setLastError(error: string | undefined): void {
60
+ currentBag().error = error === undefined ? undefined : redactSecrets(error).slice(0, 800);
61
+ }
62
+ export function setLastResolvedRuntimeModel(model: string | undefined): void {
63
+ currentBag().resolvedRuntimeModel = model;
64
+ }
65
+ export function setLastAvailableModels(models: string | undefined): void {
66
+ currentBag().availableModels = models;
67
+ }
68
+ export function setLastRpc(rpc: string | undefined): void {
69
+ currentBag().lastRpc = rpc;
70
+ }
71
+ export function setLastMatchedModelDebug(debug: string | undefined): void {
72
+ currentBag().matchedModelDebug =
73
+ debug === undefined ? undefined : redactSecrets(debug).slice(0, 1200);
74
+ }
75
+ export function setLastTokenSource(source: string | undefined): void {
76
+ currentBag().tokenSource = source;
77
+ }
78
+ export function setLastClientVersion(version: string | undefined): void {
79
+ currentBag().clientVersion = version;
80
+ }
81
+ export function setLastRecoverySkipReason(reason: string | undefined): void {
82
+ currentBag().lastRecoverySkipReason =
83
+ reason === undefined ? undefined : redactSecrets(reason).slice(0, 200);
84
+ }
85
+ export function setSystemCredentialsPolicy(policy: string | undefined): void {
86
+ currentBag().systemCredentials = policy;
87
+ }
88
+ export function setLastStreamEvent(event: string | undefined): void {
89
+ currentBag().lastStreamEvent =
90
+ event === undefined ? undefined : redactSecrets(event).slice(0, 200);
91
+ }
92
+ export function setLastDriftSignal(signal: string | undefined): void {
93
+ currentBag().lastDriftSignal =
94
+ signal === undefined ? undefined : redactSecrets(signal).slice(0, 200);
95
+ }
96
+ export function setLastRequestSize(summary: string | undefined): void {
97
+ currentBag().lastRequestSize =
98
+ summary === undefined ? undefined : redactSecrets(summary).slice(0, 400);
99
+ }
100
+ export function setLastIdleTimeout(info: {
101
+ timeoutMs: number;
102
+ attempt: number;
103
+ event?: string;
104
+ }): void {
105
+ const bag = currentBag();
106
+ bag.lastIdleTimeoutAt = new Date().toISOString();
107
+ bag.lastIdleTimeoutMs = info.timeoutMs;
108
+ bag.lastIdleAttempt = info.attempt;
109
+ if (info.event) {
110
+ bag.lastStreamEvent = redactSecrets(info.event).slice(0, 200);
111
+ }
112
+ }
113
+
114
+ export function resetDiagnosticsForTests(): void {
115
+ lastSnapshot = {};
116
+ }
@@ -0,0 +1 @@
1
+ export * from "./diagnostics.js";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Extension authentication and startup token resolution.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ getCursorAccessTokenFromEnv,
10
+ getTokenExpiry,
11
+ refreshCursorToken,
12
+ type CursorCredentials,
13
+ } from "../auth/oauth.js";
14
+ import { resolveSystemCursorAccessToken } from "../auth/cli-credentials.js";
15
+ import { CredentialSource, ProviderConstant } from "../types/enums.js";
16
+
17
+ export interface ResolvedAccessToken {
18
+ accessToken: string;
19
+ source: CredentialSource;
20
+ }
21
+
22
+ function isUsableAccessToken(token: string): boolean {
23
+ try {
24
+ return Date.now() < getTokenExpiry(token);
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** Best-effort write of a rotated OAuth pair back into Pi's auth.json. */
31
+ export function persistRefreshedPiOAuth(credentials: CursorCredentials): void {
32
+ try {
33
+ const authPath = join(getAgentDir(), "auth.json");
34
+ const parsed: unknown = JSON.parse(readFileSync(authPath, "utf8"));
35
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
36
+ const data = parsed as Record<string, unknown>;
37
+ const existing = data[ProviderConstant.ProviderId];
38
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) return;
39
+ data[ProviderConstant.ProviderId] = {
40
+ ...(existing as Record<string, unknown>),
41
+ type: "oauth",
42
+ access: credentials.access,
43
+ refresh: credentials.refresh,
44
+ expires: credentials.expires,
45
+ };
46
+ writeFileSync(authPath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
47
+ } catch {
48
+ // The process still has a usable access token; next launch may need /login.
49
+ }
50
+ }
51
+
52
+ export async function getStoredCursorOAuthAccessToken(options?: {
53
+ forceRefresh?: boolean;
54
+ }): Promise<ResolvedAccessToken | undefined> {
55
+ const credential = readStoredCredential(ProviderConstant.ProviderId);
56
+ if (!credential || credential.type !== "oauth") return undefined;
57
+
58
+ if (!options?.forceRefresh && credential.access && Date.now() < credential.expires) {
59
+ return { accessToken: credential.access, source: CredentialSource.PiOAuth };
60
+ }
61
+
62
+ if (credential.refresh) {
63
+ try {
64
+ const refreshed = await refreshCursorToken(credential.refresh);
65
+ persistRefreshedPiOAuth(refreshed);
66
+ return { accessToken: refreshed.access, source: CredentialSource.PiOAuthRefresh };
67
+ } catch {
68
+ if (credential.access && Date.now() < credential.expires) {
69
+ return { accessToken: credential.access, source: CredentialSource.PiOAuth };
70
+ }
71
+ return undefined;
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export async function getStartupCursorAccessToken(options?: {
78
+ forceRefresh?: boolean;
79
+ }): Promise<ResolvedAccessToken | undefined> {
80
+ const envToken = getCursorAccessTokenFromEnv();
81
+ if (envToken && (!options?.forceRefresh || isUsableAccessToken(envToken))) {
82
+ return { accessToken: envToken, source: CredentialSource.Env };
83
+ }
84
+
85
+ // Explicit `/login cursor` must win over IDE/CLI harvest so the user is not
86
+ // silently billed to a different Cursor account.
87
+ const oauth = await getStoredCursorOAuthAccessToken(options);
88
+ if (oauth) return oauth;
89
+
90
+ return resolveSystemCursorAccessToken(options);
91
+ }
92
+
93
+ export function isTokenNearExpiry(token: string, skewMs = 60_000): boolean {
94
+ try {
95
+ return Date.now() >= getTokenExpiry(token) - skewMs;
96
+ } catch {
97
+ return true;
98
+ }
99
+ }