pi-cursor-sdk 0.1.15 → 0.1.16

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.
@@ -6,17 +6,34 @@ import {
6
6
  type Model,
7
7
  type SimpleStreamOptions,
8
8
  type AssistantMessage,
9
+ type ToolResultMessage,
9
10
  } from "@earendil-works/pi-ai";
10
11
  import { AsyncLocalStorage } from "node:async_hooks";
11
12
  import { Agent, createAgentPlatform } from "@cursor/sdk";
12
13
  import type { InteractionUpdate, SDKAgent, SettingSource } from "@cursor/sdk";
13
14
  import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
14
- import { buildCursorPrompt, type CursorPrompt } from "./context.js";
15
+ import { buildCursorSendPrompt } from "./context.js";
16
+ import {
17
+ acquireSessionCursorAgent,
18
+ commitSessionAgentSend,
19
+ disposeAllSessionCursorAgents,
20
+ resetSessionCursorAgent,
21
+ } from "./cursor-session-agent.js";
15
22
  import {
16
- getRegisteredCursorPiToolBridge,
17
23
  type CursorPiBridgeToolRequest,
18
24
  type CursorPiToolBridgeRun,
19
25
  } from "./cursor-pi-tool-bridge.js";
26
+ import {
27
+ consumeCursorLiveToolResults,
28
+ createCursorLiveRunAccountingState,
29
+ takeCursorLiveTurnInputTokens,
30
+ type CursorLiveRunAccountingState,
31
+ } from "./cursor-live-run-accounting.js";
32
+ import {
33
+ applyCursorApproximateUsage,
34
+ estimateCursorPromptInputTokens,
35
+ getCursorPromptOptions,
36
+ } from "./cursor-usage-accounting.js";
20
37
  import { getCursorSessionCwd } from "./cursor-session-cwd.js";
21
38
  import { getEffectiveFastForModelId } from "./cursor-state.js";
22
39
  import { buildCursorModelSelection } from "./model-discovery.js";
@@ -64,8 +81,6 @@ const GENERIC_CURSOR_SDK_ERROR_MESSAGE =
64
81
  "Cursor SDK request failed. The API key may be missing, invalid, or unauthorized. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
65
82
  const AUTH_CURSOR_SDK_ERROR_MESSAGE =
66
83
  "Cursor SDK request failed because the API key may be invalid or unauthorized. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
67
- const APPROX_CHARS_PER_TOKEN = 4;
68
- const IMAGE_TOKEN_ESTIMATE = 1200;
69
84
  const CURSOR_ACTIVITY_TRACE_MAX_CHARS = 50000;
70
85
  const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
71
86
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
@@ -79,12 +94,18 @@ type CursorLiveQueuedEvent =
79
94
  | { type: "tool"; tool: CursorNativeToolDisplayItem }
80
95
  | { type: "bridge-tool"; request: CursorPiBridgeToolRequest };
81
96
 
97
+ interface CursorLiveSdkRun {
98
+ cancel(): Promise<void>;
99
+ }
100
+
82
101
  interface CursorLiveRun {
83
102
  id: string;
84
103
  agent: SDKAgent;
85
104
  bridgeRun?: CursorPiToolBridgeRun;
86
- promptInputTokens: number;
87
- promptInputTokensReported: boolean;
105
+ sessionBridgeRun?: CursorPiToolBridgeRun;
106
+ sessionAgentScopeKey?: string;
107
+ sdkRun?: CursorLiveSdkRun;
108
+ accounting: CursorLiveRunAccountingState;
88
109
  pendingEvents: CursorLiveQueuedEvent[];
89
110
  textDeltas: string[];
90
111
  emittedText: string;
@@ -103,6 +124,7 @@ interface CursorLiveTurnState {
103
124
  partial: AssistantMessage;
104
125
  thinkingContentIndex: number;
105
126
  textContentIndex: number;
127
+ emittedText: string;
106
128
  }
107
129
 
108
130
  let cursorNativeReplayCounter = 0;
@@ -262,27 +284,6 @@ async function cacheSdkContextWindow(agentId: string, modelId: string): Promise<
262
284
  }
263
285
  }
264
286
 
265
- function estimateTextTokens(text: string): number {
266
- return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
267
- }
268
-
269
- function estimatePromptInputTokens(prompt: CursorPrompt): number {
270
- return estimateTextTokens(prompt.text) + prompt.images.length * IMAGE_TOKEN_ESTIMATE;
271
- }
272
-
273
- function getPromptInputTokenBudget(model: Model<Api>): number {
274
- const outputReserveTokens = Math.min(model.maxTokens, Math.max(1, Math.floor(model.contextWindow * 0.2)));
275
- return Math.max(1, model.contextWindow - outputReserveTokens);
276
- }
277
-
278
- function setApproximateUsage(partial: AssistantMessage, promptInputTokens: number, outputText: string): void {
279
- partial.usage.input = promptInputTokens;
280
- partial.usage.output = estimateTextTokens(outputText);
281
- partial.usage.cacheRead = 0;
282
- partial.usage.cacheWrite = 0;
283
- partial.usage.totalTokens = partial.usage.input + partial.usage.output;
284
- }
285
-
286
287
  function sanitizeSingleLine(value: string): string {
287
288
  return value.replace(/\s+/g, " ").trim();
288
289
  }
@@ -391,6 +392,18 @@ function getPendingCursorLiveRun(context: Context): CursorLiveRun | undefined {
391
392
  return undefined;
392
393
  }
393
394
 
395
+ function isCursorLiveRunToolResult(run: CursorLiveRun, message: ToolResultMessage): boolean {
396
+ const replayId = getCursorNativeReplayIdFromToolCallId(message.toolCallId);
397
+ if (replayId) return replayId === run.id;
398
+ return run.bridgeRun?.hasPendingPiToolCallId(message.toolCallId) ?? false;
399
+ }
400
+
401
+ function consumeCursorLiveRunToolResults(run: CursorLiveRun, context: Context) {
402
+ const consumed = consumeCursorLiveToolResults(run.accounting, context, (toolResult) => isCursorLiveRunToolResult(run, toolResult));
403
+ run.accounting = consumed.state;
404
+ return consumed;
405
+ }
406
+
394
407
  function splitTextIntoReplayDeltas(text: string): string[] {
395
408
  const deltas: string[] = [];
396
409
  let remaining = text;
@@ -446,7 +459,7 @@ function scheduleCursorNativeRunIdleDispose(run: CursorLiveRun): void {
446
459
  if (run.disposed) return;
447
460
  clearCursorNativeRunIdleDispose(run);
448
461
  run.idleDisposeTimer = setTimeout(() => {
449
- void disposeCursorNativeRun(run);
462
+ void releaseCursorLiveRun(run);
450
463
  }, cursorNativeReplayIdleDisposeMs);
451
464
  run.idleDisposeTimer.unref?.();
452
465
  }
@@ -557,6 +570,7 @@ function emitCursorLiveQueuedEvent(
557
570
  } else if (event.type === "thinking-completed") {
558
571
  closeCursorNativeThinkingBlock(turn);
559
572
  } else if (event.type === "text-delta") {
573
+ turn.emittedText += event.text;
560
574
  if (run) run.emittedText += event.text;
561
575
  emitCursorNativeTextDelta(turn, event.text);
562
576
  }
@@ -580,44 +594,72 @@ function collectCursorBridgeToolBatch(run: CursorLiveRun): CursorPiBridgeToolReq
580
594
  return requests;
581
595
  }
582
596
 
583
- function trimAlreadyEmittedCursorText(text: string, emittedText: string): string {
597
+ function isCursorTextBoundary(text: string, index: number): boolean {
598
+ if (index <= 0 || index >= text.length) return true;
599
+ const before = text[index - 1];
600
+ const after = text[index];
601
+ return !/[\p{L}\p{N}_]/u.test(before) || !/[\p{L}\p{N}_]/u.test(after);
602
+ }
603
+
604
+ function trimAlreadyEmittedCursorText(text: string, emittedText: string, options?: { allowPartialPrefix?: boolean }): string {
584
605
  if (!text || !emittedText) return text;
585
606
  if (text === emittedText) return "";
586
- if (text.startsWith(emittedText)) return text.slice(emittedText.length);
587
- if (emittedText.endsWith(text)) return "";
588
- if (text.trim() === emittedText.trim()) return "";
589
- if (emittedText.trim().endsWith(text.trim())) return "";
607
+ if (text.startsWith(emittedText) && (options?.allowPartialPrefix || isCursorTextBoundary(text, emittedText.length))) {
608
+ return text.slice(emittedText.length);
609
+ }
610
+ if (emittedText.endsWith(text) && isCursorTextBoundary(emittedText, emittedText.length - text.length)) return "";
611
+ const trimmedText = text.trim();
612
+ const trimmedEmittedText = emittedText.trim();
613
+ if (trimmedText === trimmedEmittedText) return "";
614
+ if (trimmedText && trimmedEmittedText.endsWith(trimmedText)) {
615
+ const suffixStart = trimmedEmittedText.length - trimmedText.length;
616
+ if (isCursorTextBoundary(trimmedEmittedText, suffixStart)) return "";
617
+ }
590
618
  return text;
591
619
  }
592
620
 
621
+ function trimCurrentTurnAlreadyEmittedCursorText(text: string, currentTurnEmittedText: string, emittedText = currentTurnEmittedText): string {
622
+ if (!currentTurnEmittedText) return trimAlreadyEmittedCursorText(text, emittedText);
623
+ const currentTurnTrimmedText = trimAlreadyEmittedCursorText(text, currentTurnEmittedText, { allowPartialPrefix: true });
624
+ if (currentTurnTrimmedText !== text) return currentTurnTrimmedText;
625
+ if (emittedText.endsWith(currentTurnEmittedText)) {
626
+ const emittedTextTrimmedText = trimAlreadyEmittedCursorText(text, emittedText, { allowPartialPrefix: true });
627
+ if (emittedTextTrimmedText !== text) return emittedTextTrimmedText;
628
+ }
629
+ return trimAlreadyEmittedCursorText(text, emittedText);
630
+ }
631
+
593
632
  function selectCursorFinalText(
594
633
  resultText: unknown,
595
634
  textDeltas: readonly string[],
596
635
  emittedText: string,
597
636
  fallbackText?: string,
637
+ options?: { allowPartialPrefix?: boolean },
598
638
  ): string {
599
639
  const candidates = [typeof resultText === "string" ? resultText : undefined, fallbackText, textDeltas.join("")];
600
640
  for (const candidate of candidates) {
601
641
  if (!hasUsableText(candidate)) continue;
602
- const trimmedCandidate = trimAlreadyEmittedCursorText(candidate, emittedText);
642
+ const trimmedCandidate = trimAlreadyEmittedCursorText(candidate, emittedText, options);
603
643
  if (hasUsableText(trimmedCandidate)) return trimmedCandidate;
604
644
  }
605
645
  return "";
606
646
  }
607
647
 
608
- function takeCursorNativePromptInputTokens(run: CursorLiveRun): number {
648
+ function takeCursorLiveSessionInputTokens(run: CursorLiveRun, toolResultInputTokens: number): number {
609
649
  // Native replay can split one Cursor run into multiple pi turns; count prompt input once.
610
- if (run.promptInputTokensReported) return 0;
611
- run.promptInputTokensReported = true;
612
- return run.promptInputTokens;
650
+ const taken = takeCursorLiveTurnInputTokens(run.accounting, toolResultInputTokens);
651
+ run.accounting = taken.state;
652
+ return taken.sessionInputTokens;
613
653
  }
614
654
 
615
655
  function emitCursorNativeToolUseTurn(
616
656
  stream: AssistantMessageEventStream,
617
657
  partial: AssistantMessage,
658
+ model: Model<Api>,
659
+ context: Context,
618
660
  run: CursorLiveRun,
661
+ toolResultInputTokens: number,
619
662
  tools: CursorNativeToolDisplayItem[],
620
- outputText: string,
621
663
  ): void {
622
664
  const shouldTerminate = run.done && !run.finalText?.trim() && run.pendingEvents.length === 0;
623
665
  for (const tool of tools) {
@@ -636,7 +678,7 @@ function emitCursorNativeToolUseTurn(
636
678
  run.recordedToolDisplayIds.push(tool.id);
637
679
  }
638
680
  }
639
- setApproximateUsage(partial, takeCursorNativePromptInputTokens(run), outputText);
681
+ applyCursorApproximateUsage(partial, model, context, takeCursorLiveSessionInputTokens(run, toolResultInputTokens));
640
682
  partial.stopReason = "toolUse";
641
683
  stream.push({ type: "done", reason: "toolUse", message: partial });
642
684
  scheduleCursorNativeRunIdleDispose(run);
@@ -645,9 +687,11 @@ function emitCursorNativeToolUseTurn(
645
687
  function emitCursorBridgeToolUseTurn(
646
688
  stream: AssistantMessageEventStream,
647
689
  partial: AssistantMessage,
690
+ model: Model<Api>,
691
+ context: Context,
648
692
  run: CursorLiveRun,
693
+ toolResultInputTokens: number,
649
694
  requests: CursorPiBridgeToolRequest[],
650
- outputText: string,
651
695
  ): void {
652
696
  for (const request of requests) {
653
697
  const contentIndex = partial.content.length;
@@ -662,37 +706,58 @@ function emitCursorBridgeToolUseTurn(
662
706
  const block = partial.content[contentIndex];
663
707
  if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
664
708
  }
665
- setApproximateUsage(partial, takeCursorNativePromptInputTokens(run), outputText);
709
+ applyCursorApproximateUsage(partial, model, context, takeCursorLiveSessionInputTokens(run, toolResultInputTokens));
666
710
  partial.stopReason = "toolUse";
667
711
  stream.push({ type: "done", reason: "toolUse", message: partial });
668
712
  scheduleCursorNativeRunIdleDispose(run);
669
713
  }
670
714
 
671
- async function disposeCursorNativeRun(run: CursorLiveRun): Promise<void> {
715
+ function isSuccessfulCursorLiveRun(run: CursorLiveRun): boolean {
716
+ return run.done && !run.cancelled && !run.errorMessage;
717
+ }
718
+
719
+ async function abandonSessionCursorAgent(scopeKey: string | undefined): Promise<void> {
720
+ if (!scopeKey) return;
721
+ await resetSessionCursorAgent(scopeKey);
722
+ }
723
+
724
+ async function releaseCursorLiveRun(run: CursorLiveRun): Promise<void> {
672
725
  if (run.disposed) return;
726
+ const abandoned = !isSuccessfulCursorLiveRun(run);
673
727
  run.disposed = true;
674
728
  pendingCursorLiveRuns.delete(run.id);
675
729
  clearCursorNativeRunIdleDispose(run);
676
- run.bridgeRun?.cancel("Cursor live run disposed");
730
+ run.bridgeRun?.cancel("Cursor live run released");
677
731
  for (const toolDisplayId of run.recordedToolDisplayIds) deleteCursorNativeToolDisplay(toolDisplayId);
678
732
  run.recordedToolDisplayIds = [];
679
733
  run.waiters.clear();
680
- try {
681
- await run.bridgeRun?.dispose();
682
- } catch {
683
- // bridge disposal failure should not mask the provider result
734
+ if (run.sessionBridgeRun) {
735
+ run.sessionBridgeRun.setOnToolRequest(undefined);
684
736
  }
685
- try {
686
- await run.agent[Symbol.asyncDispose]();
687
- } catch {
688
- // disposal failure should not mask the provider result
737
+ if (run.bridgeRun && run.bridgeRun !== run.sessionBridgeRun) {
738
+ try {
739
+ await run.bridgeRun.dispose();
740
+ } catch {
741
+ // bridge disposal failure should not mask the provider result
742
+ }
743
+ }
744
+ if (abandoned) {
745
+ try {
746
+ await run.sdkRun?.cancel();
747
+ } catch {
748
+ // cancellation failure should not block session-agent abandonment
749
+ }
750
+ await abandonSessionCursorAgent(run.sessionAgentScopeKey);
689
751
  }
690
752
  }
691
753
 
692
754
  async function emitCursorNativeRunNextTurn(
693
755
  stream: AssistantMessageEventStream,
694
756
  partial: AssistantMessage,
757
+ model: Model<Api>,
758
+ context: Context,
695
759
  run: CursorLiveRun,
760
+ toolResultInputTokens: number,
696
761
  signal?: AbortSignal,
697
762
  ): Promise<void> {
698
763
  const turn: CursorLiveTurnState = {
@@ -700,6 +765,7 @@ async function emitCursorNativeRunNextTurn(
700
765
  partial,
701
766
  thinkingContentIndex: -1,
702
767
  textContentIndex: -1,
768
+ emittedText: "",
703
769
  };
704
770
 
705
771
  while (true) {
@@ -708,17 +774,17 @@ async function emitCursorNativeRunNextTurn(
708
774
  if (event.type === "tool") {
709
775
  await settleCursorLiveToolBatch(run);
710
776
  if (signal?.aborted) throw new CursorAbortError();
711
- const outputText = closeCursorNativeTurnBlocks(turn);
777
+ closeCursorNativeTurnBlocks(turn);
712
778
  const tools = collectCursorNativeToolBatch(run);
713
- emitCursorNativeToolUseTurn(stream, partial, run, tools, outputText);
779
+ emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, tools);
714
780
  return;
715
781
  }
716
782
  if (event.type === "bridge-tool") {
717
783
  await settleCursorLiveToolBatch(run);
718
784
  if (signal?.aborted) throw new CursorAbortError();
719
- const outputText = closeCursorNativeTurnBlocks(turn);
785
+ closeCursorNativeTurnBlocks(turn);
720
786
  const requests = collectCursorBridgeToolBatch(run);
721
- emitCursorBridgeToolUseTurn(stream, partial, run, requests, outputText);
787
+ emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests);
722
788
  return;
723
789
  }
724
790
  run.pendingEvents.shift();
@@ -728,26 +794,26 @@ async function emitCursorNativeRunNextTurn(
728
794
  if (run.cancelled) {
729
795
  partial.stopReason = "aborted";
730
796
  stream.push({ type: "error", reason: "aborted", error: partial });
731
- await disposeCursorNativeRun(run);
797
+ await releaseCursorLiveRun(run);
732
798
  return;
733
799
  }
734
800
  if (run.errorMessage) {
735
801
  partial.stopReason = "error";
736
802
  partial.errorMessage = run.errorMessage;
737
803
  stream.push({ type: "error", reason: "error", error: partial });
738
- await disposeCursorNativeRun(run);
804
+ await releaseCursorLiveRun(run);
739
805
  return;
740
806
  }
741
807
  if (run.done) {
742
- let outputText = closeCursorNativeTurnBlocks(turn);
743
- const finalText = trimAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), run.emittedText);
808
+ closeCursorNativeTurnBlocks(turn);
809
+ const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
744
810
  if (finalText) {
745
- outputText += await emitTextDeltas(stream, partial, splitTextIntoReplayDeltas(finalText));
811
+ await emitTextDeltas(stream, partial, splitTextIntoReplayDeltas(finalText));
746
812
  }
747
- setApproximateUsage(partial, takeCursorNativePromptInputTokens(run), outputText);
813
+ applyCursorApproximateUsage(partial, model, context, takeCursorLiveSessionInputTokens(run, toolResultInputTokens));
748
814
  partial.stopReason = "stop";
749
815
  stream.push({ type: "done", reason: "stop", message: partial });
750
- await disposeCursorNativeRun(run);
816
+ await releaseCursorLiveRun(run);
751
817
  return;
752
818
  }
753
819
 
@@ -758,17 +824,19 @@ async function emitCursorNativeRunNextTurn(
758
824
  async function replayPendingCursorLiveRun(
759
825
  stream: AssistantMessageEventStream,
760
826
  partial: AssistantMessage,
827
+ model: Model<Api>,
761
828
  context: Context,
762
829
  signal?: AbortSignal,
763
830
  ): Promise<boolean> {
764
831
  const run = getPendingCursorLiveRun(context);
765
832
  if (!run) return false;
766
833
  clearCursorNativeRunIdleDispose(run);
767
- run.bridgeRun?.resolveToolResultsFromContext(context);
834
+ const consumed = consumeCursorLiveRunToolResults(run, context);
835
+ run.bridgeRun?.resolveToolResults(consumed.toolResults);
768
836
  try {
769
- await emitCursorNativeRunNextTurn(stream, partial, run, signal);
837
+ await emitCursorNativeRunNextTurn(stream, partial, model, context, run, consumed.toolResultInputTokens, signal);
770
838
  } catch (error) {
771
- if (error instanceof CursorAbortError) await disposeCursorNativeRun(run);
839
+ if (error instanceof CursorAbortError) await releaseCursorLiveRun(run);
772
840
  throw error;
773
841
  }
774
842
  return true;
@@ -786,10 +854,10 @@ export function streamCursor(
786
854
  let agent: SDKAgent | null = null;
787
855
  let activeLiveRun: CursorLiveRun | undefined;
788
856
  let bridgeRun: CursorPiToolBridgeRun | undefined;
789
- let bridgeRunOwnedByLiveRun = false;
790
857
  let liveRunForBridgeQueue: CursorLiveRun | undefined;
791
858
  const queuedBridgeRequestsBeforeLiveRun: CursorPiBridgeToolRequest[] = [];
792
859
  let resolvedApiKey: string | undefined;
860
+ let sessionAgentScopeKey = "";
793
861
  let abortSignal: AbortSignal | undefined;
794
862
  let abortListener: (() => void) | undefined;
795
863
  let restoreCursorSdkOutputFilter: (() => void) | undefined;
@@ -802,7 +870,7 @@ export function streamCursor(
802
870
  stream.push({ type: "start", partial });
803
871
  throwIfAborted();
804
872
 
805
- if (await replayPendingCursorLiveRun(stream, partial, context, options?.signal)) {
873
+ if (await replayPendingCursorLiveRun(stream, partial, model, context, options?.signal)) {
806
874
  stream.end();
807
875
  return;
808
876
  }
@@ -818,41 +886,41 @@ export function streamCursor(
818
886
  const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
819
887
  const settingSources = resolveCursorSettingSources();
820
888
 
821
- const registeredBridge = getRegisteredCursorPiToolBridge();
822
- bridgeRun = registeredBridge
823
- ? await registeredBridge.createRun({
824
- onToolRequest: (request) => {
825
- if (liveRunForBridgeQueue && !liveRunForBridgeQueue.disposed) {
826
- queueCursorNativeEvent(liveRunForBridgeQueue, { type: "bridge-tool", request });
827
- } else {
828
- queuedBridgeRequestsBeforeLiveRun.push(request);
829
- }
830
- },
831
- })
832
- : undefined;
833
- if (!bridgeRun?.enabled || !bridgeRun.mcpServers) {
834
- await bridgeRun?.dispose();
835
- bridgeRun = undefined;
836
- }
837
-
838
889
  installCursorMcpToolTimeoutOverride();
839
890
  restoreCursorSdkOutputFilter = installCursorSdkOutputFilter();
840
- agent = await suppressCursorSdkOutput(() =>
841
- Agent.create({
842
- apiKey,
843
- model: selection,
844
- local: settingSources ? { cwd, settingSources } : { cwd },
845
- ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}),
846
- }),
847
- );
891
+ const sessionAgentAcquireParams = {
892
+ apiKey,
893
+ cwd,
894
+ modelSelection: selection,
895
+ settingSources,
896
+ onBridgeToolRequest: (request: CursorPiBridgeToolRequest) => {
897
+ if (liveRunForBridgeQueue && !liveRunForBridgeQueue.disposed) {
898
+ queueCursorNativeEvent(liveRunForBridgeQueue, { type: "bridge-tool", request });
899
+ } else {
900
+ queuedBridgeRequestsBeforeLiveRun.push(request);
901
+ }
902
+ },
903
+ createAgent: (createOptions: Parameters<typeof Agent.create>[0]) =>
904
+ suppressCursorSdkOutput(() => Agent.create(createOptions)),
905
+ };
906
+ let sessionAgentLease = await acquireSessionCursorAgent(sessionAgentAcquireParams);
907
+ sessionAgentScopeKey = sessionAgentLease.scopeKey;
908
+ agent = sessionAgentLease.agent;
909
+ bridgeRun = sessionAgentLease.bridgeRun;
848
910
  throwIfAborted();
849
911
 
850
- const prompt = buildCursorPrompt(context, {
851
- maxInputTokens: getPromptInputTokenBudget(model),
852
- charsPerToken: APPROX_CHARS_PER_TOKEN,
853
- imageTokenEstimate: IMAGE_TOKEN_ESTIMATE,
854
- });
855
- const promptInputTokens = estimatePromptInputTokens(prompt);
912
+ const promptOptions = getCursorPromptOptions(model);
913
+ let { prompt, bootstrap } = buildCursorSendPrompt(context, promptOptions, sessionAgentLease.sendState);
914
+ if (sessionAgentLease.sendState.bootstrapped && bootstrap) {
915
+ await resetSessionCursorAgent(sessionAgentLease.scopeKey);
916
+ sessionAgentLease = await acquireSessionCursorAgent(sessionAgentAcquireParams);
917
+ sessionAgentScopeKey = sessionAgentLease.scopeKey;
918
+ agent = sessionAgentLease.agent;
919
+ bridgeRun = sessionAgentLease.bridgeRun;
920
+ ({ prompt, bootstrap } = buildCursorSendPrompt(context, promptOptions, sessionAgentLease.sendState));
921
+ }
922
+ const sessionBridgeRun = sessionAgentLease.bridgeRun;
923
+ const promptInputTokens = estimateCursorPromptInputTokens(prompt, promptOptions);
856
924
  let thinkingContentIndex = -1;
857
925
  let activityTraceChars = 0;
858
926
  let activityTraceTruncated = false;
@@ -868,8 +936,9 @@ export function streamCursor(
868
936
  id: useNativeToolReplay ? nativeReplayId : bridgeRun!.id,
869
937
  agent,
870
938
  bridgeRun,
871
- promptInputTokens,
872
- promptInputTokensReported: false,
939
+ sessionBridgeRun,
940
+ sessionAgentScopeKey,
941
+ accounting: createCursorLiveRunAccountingState(promptInputTokens),
873
942
  pendingEvents: [],
874
943
  textDeltas,
875
944
  emittedText: "",
@@ -884,7 +953,6 @@ export function streamCursor(
884
953
  pendingCursorLiveRuns.set(liveRun.id, liveRun);
885
954
  activeLiveRun = liveRun;
886
955
  liveRunForBridgeQueue = liveRun;
887
- bridgeRunOwnedByLiveRun = bridgeRun !== undefined;
888
956
  for (const request of queuedBridgeRequestsBeforeLiveRun.splice(0)) {
889
957
  queueCursorNativeEvent(liveRun, { type: "bridge-tool", request });
890
958
  }
@@ -1218,6 +1286,7 @@ export function streamCursor(
1218
1286
  { text: prompt.text, images: prompt.images.length > 0 ? prompt.images : undefined },
1219
1287
  { onDelta, onStep },
1220
1288
  );
1289
+ if (liveRun) liveRun.sdkRun = run;
1221
1290
  if (options?.signal?.aborted) {
1222
1291
  await run.cancel().catch(() => {});
1223
1292
  throw new CursorAbortError();
@@ -1231,14 +1300,24 @@ export function streamCursor(
1231
1300
  discardIncompleteStartedToolCalls();
1232
1301
  await cacheSdkContextWindow(liveRun.agent.agentId, model.id);
1233
1302
  if (liveRun.disposed) return;
1303
+ if (result.status === "finished" && !options?.signal?.aborted) {
1304
+ commitSessionAgentSend(sessionAgentScopeKey, context, bootstrap);
1305
+ } else {
1306
+ await abandonSessionCursorAgent(sessionAgentScopeKey);
1307
+ }
1234
1308
  liveRun.cancelled = result.status === "cancelled";
1235
- liveRun.finalText = selectCursorFinalText(result.result, liveRun.textDeltas, liveRun.emittedText, cursorPlanTextCandidate);
1309
+ if (result.status === "error") {
1310
+ liveRun.errorMessage = sanitizeError(result.result ?? "Cursor SDK run failed", resolvedApiKey ?? options?.apiKey);
1311
+ } else {
1312
+ liveRun.finalText = selectCursorFinalText(result.result, liveRun.textDeltas, liveRun.emittedText, cursorPlanTextCandidate);
1313
+ }
1236
1314
  liveRun.done = true;
1237
1315
  notifyCursorNativeRun(liveRun);
1238
1316
  scheduleCursorNativeRunIdleDispose(liveRun);
1239
1317
  })
1240
- .catch((error: unknown) => {
1318
+ .catch(async (error: unknown) => {
1241
1319
  if (liveRun.disposed) return;
1320
+ await abandonSessionCursorAgent(sessionAgentScopeKey);
1242
1321
  liveRun.errorMessage = sanitizeError(error, resolvedApiKey ?? options?.apiKey);
1243
1322
  notifyCursorNativeRun(liveRun);
1244
1323
  scheduleCursorNativeRunIdleDispose(liveRun);
@@ -1248,9 +1327,9 @@ export function streamCursor(
1248
1327
  await waitForCursorNativeRunProgress(liveRun, options?.signal);
1249
1328
  await settleCursorLiveToolBatch(liveRun);
1250
1329
  closeTraceBlock();
1251
- await emitCursorNativeRunNextTurn(stream, partial, liveRun, options?.signal);
1330
+ await emitCursorNativeRunNextTurn(stream, partial, model, context, liveRun, 0, options?.signal);
1252
1331
  } catch (error) {
1253
- if (error instanceof CursorAbortError) await disposeCursorNativeRun(liveRun);
1332
+ if (error instanceof CursorAbortError) await releaseCursorLiveRun(liveRun);
1254
1333
  throw error;
1255
1334
  }
1256
1335
  agent = null;
@@ -1266,16 +1345,26 @@ export function streamCursor(
1266
1345
  closeTraceBlock();
1267
1346
 
1268
1347
  if (result.status === "cancelled") {
1348
+ await abandonSessionCursorAgent(sessionAgentScopeKey);
1269
1349
  partial.stopReason = "aborted";
1270
1350
  stream.push({ type: "error", reason: "aborted", error: partial });
1351
+ } else if (result.status === "error") {
1352
+ await abandonSessionCursorAgent(sessionAgentScopeKey);
1353
+ partial.stopReason = "error";
1354
+ partial.errorMessage = sanitizeError(result.result ?? "Cursor SDK run failed", resolvedApiKey ?? options?.apiKey);
1355
+ stream.push({ type: "error", reason: "error", error: partial });
1271
1356
  } else {
1272
- const finalCursorText = selectCursorFinalText(result.result, textDeltas, textDeltas.join(""), cursorPlanTextCandidate);
1273
- const finalText = flushText(hasUsableText(finalCursorText) ? [finalCursorText] : []);
1274
- setApproximateUsage(partial, promptInputTokens, finalText);
1357
+ commitSessionAgentSend(sessionAgentScopeKey, context, bootstrap);
1358
+ const finalCursorText = selectCursorFinalText(result.result, textDeltas, textDeltas.join(""), cursorPlanTextCandidate, {
1359
+ allowPartialPrefix: true,
1360
+ });
1361
+ flushText(hasUsableText(finalCursorText) ? [finalCursorText] : []);
1362
+ applyCursorApproximateUsage(partial, model, context, promptInputTokens);
1275
1363
  stream.push({ type: "done", reason: "stop", message: partial });
1276
1364
  }
1277
1365
  } catch (error) {
1278
- if (activeLiveRun && !activeLiveRun.disposed) await disposeCursorNativeRun(activeLiveRun);
1366
+ if (activeLiveRun && !activeLiveRun.disposed) await releaseCursorLiveRun(activeLiveRun);
1367
+ else await abandonSessionCursorAgent(sessionAgentScopeKey);
1279
1368
  if (error instanceof CursorAbortError) {
1280
1369
  partial.stopReason = "aborted";
1281
1370
  stream.push({ type: "error", reason: "aborted", error: partial });
@@ -1286,28 +1375,10 @@ export function streamCursor(
1286
1375
  }
1287
1376
  } finally {
1288
1377
  restoreCursorSdkOutputFilter?.();
1289
- if (activeLiveRun?.disposed) agent = null;
1290
1378
 
1291
1379
  if (abortSignal && abortListener) {
1292
1380
  abortSignal.removeEventListener("abort", abortListener);
1293
1381
  }
1294
-
1295
- if (bridgeRun && !bridgeRunOwnedByLiveRun) {
1296
- try {
1297
- await bridgeRun.dispose();
1298
- } catch {
1299
- // bridge disposal failure should not mask original error
1300
- }
1301
- }
1302
-
1303
- if (agent) {
1304
- try {
1305
- await agent[Symbol.asyncDispose]();
1306
- } catch {
1307
- // disposal failure should not mask original error
1308
- }
1309
- agent = null;
1310
- }
1311
1382
  }
1312
1383
 
1313
1384
  stream.end();
@@ -1325,4 +1396,5 @@ export const __testUtils = {
1325
1396
  resetCursorNativeReplayIdleDisposeMs: () => {
1326
1397
  cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
1327
1398
  },
1399
+ resetSessionCursorAgents: () => disposeAllSessionCursorAgents(),
1328
1400
  };
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge.js";
@@ -34,6 +34,11 @@ interface CursorQuestionDetails {
34
34
  cancelled: boolean;
35
35
  }
36
36
 
37
+ interface CursorQuestionToolExtensionApi extends Pick<ExtensionAPI, "getActiveTools" | "registerTool" | "setActiveTools"> {
38
+ on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
39
+ on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
40
+ }
41
+
37
42
  type RawQuestionOption = string | { label?: string; value?: string; description?: string };
38
43
 
39
44
  type RawQuestion = {
@@ -186,7 +191,7 @@ function syncCursorQuestionToolForModel(pi: Pick<ExtensionAPI, "getActiveTools"
186
191
  pi.setActiveTools([...activeToolNames]);
187
192
  }
188
193
 
189
- export function registerCursorQuestionTool(pi: ExtensionAPI): void {
194
+ export function registerCursorQuestionTool(pi: CursorQuestionToolExtensionApi): void {
190
195
  pi.registerTool({
191
196
  name: CURSOR_ASK_QUESTION_TOOL_NAME,
192
197
  label: "Cursor question",