pi-cursor-sdk 0.1.37 → 0.1.38

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 (73) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/docs/cursor-native-tool-replay.md +3 -3
  3. package/package.json +1 -1
  4. package/scripts/platform-smoke/card-detect.mjs +1 -1
  5. package/src/context-window-cache.ts +10 -14
  6. package/src/context.ts +1 -1
  7. package/src/cursor-agent-message-web-tools.ts +2 -1
  8. package/src/cursor-agents-context-registration.ts +18 -0
  9. package/src/cursor-agents-context.ts +21 -30
  10. package/src/cursor-edit-diff.ts +4 -2
  11. package/src/cursor-fallback-warning.ts +22 -0
  12. package/src/cursor-incomplete-tool-visibility.ts +5 -10
  13. package/src/cursor-live-run-coordinator.ts +1 -1
  14. package/src/cursor-mcp-timeout-override.ts +0 -2
  15. package/src/cursor-model-lifecycle.ts +72 -0
  16. package/src/cursor-native-replay-routing.ts +1 -1
  17. package/src/cursor-native-replay-trace.ts +1 -1
  18. package/src/cursor-native-tool-display-registration.ts +16 -28
  19. package/src/cursor-native-tool-display-replay.ts +4 -21
  20. package/src/cursor-native-tool-display-state.ts +1 -1
  21. package/src/cursor-native-tool-display-tools.ts +10 -17
  22. package/src/cursor-native-tool-names.ts +16 -0
  23. package/src/cursor-pi-tool-bridge-env.ts +12 -0
  24. package/src/cursor-pi-tool-bridge-mcp.ts +16 -21
  25. package/src/cursor-pi-tool-bridge-run.ts +5 -5
  26. package/src/cursor-pi-tool-bridge-server.ts +8 -3
  27. package/src/cursor-pi-tool-bridge-snapshot.ts +7 -13
  28. package/src/cursor-pi-tool-bridge.ts +7 -7
  29. package/src/cursor-provider-lazy.ts +51 -0
  30. package/src/cursor-provider-live-run-drain.ts +1 -1
  31. package/src/cursor-provider-run-finalizer.ts +5 -5
  32. package/src/cursor-provider-run-outcome.ts +0 -1
  33. package/src/cursor-provider-turn-coordinator.ts +4 -5
  34. package/src/cursor-provider-turn-display-router.ts +5 -1
  35. package/src/cursor-provider-turn-emit.ts +1 -1
  36. package/src/cursor-provider-turn-lifecycle-emitter.ts +1 -5
  37. package/src/cursor-provider-turn-prepare.ts +13 -9
  38. package/src/cursor-provider-turn-runner.ts +3 -11
  39. package/src/cursor-provider-turn-sdk-normalizer.ts +28 -5
  40. package/src/cursor-provider-turn-send.ts +7 -2
  41. package/src/cursor-provider-turn-types.ts +1 -3
  42. package/src/cursor-provider.ts +3 -2
  43. package/src/cursor-question-tool.ts +5 -18
  44. package/src/cursor-record-utils.ts +42 -0
  45. package/src/cursor-replay-activity-builders.ts +16 -122
  46. package/src/cursor-replay-tool-details.ts +52 -80
  47. package/src/cursor-sdk-event-debug.ts +6 -6
  48. package/src/cursor-sensitive-text.ts +4 -4
  49. package/src/cursor-session-agent-lifecycle.ts +47 -0
  50. package/src/cursor-session-agent.ts +9 -47
  51. package/src/cursor-session-scope.ts +23 -4
  52. package/src/cursor-setting-sources.ts +8 -8
  53. package/src/cursor-skill-tool.ts +25 -32
  54. package/src/cursor-state.ts +66 -45
  55. package/src/cursor-tool-lifecycle.ts +16 -9
  56. package/src/cursor-tool-presentation-registry.ts +27 -18
  57. package/src/cursor-tool-result-display-readers.ts +185 -0
  58. package/src/cursor-tool-transcript.ts +17 -33
  59. package/src/cursor-tool-visibility.ts +9 -1
  60. package/src/cursor-transcript-tool-formatters.ts +23 -172
  61. package/src/cursor-transcript-tool-specs.ts +16 -41
  62. package/src/cursor-transcript-utils.ts +2 -34
  63. package/src/cursor-usage-accounting.ts +0 -6
  64. package/src/cursor-web-tool-activity.ts +4 -12
  65. package/src/cursor-web-tool-args.ts +1 -9
  66. package/src/index.ts +15 -16
  67. package/src/model-discovery.ts +5 -4
  68. package/src/model-list-cache.ts +37 -38
  69. package/src/cursor-native-tool-display.ts +0 -10
  70. package/src/cursor-provider-turn-api-key.ts +0 -1
  71. package/src/cursor-provider-turn-message-offset.ts +0 -15
  72. package/src/cursor-session-cwd.ts +0 -28
  73. package/src/cursor-tool-names.ts +0 -9
@@ -1,10 +1,3 @@
1
- import type {
2
- ExtensionHandler,
3
- SessionBeforeTreeEvent,
4
- SessionCompactEvent,
5
- SessionShutdownEvent,
6
- SessionTreeEvent,
7
- } from "@earendil-works/pi-coding-agent";
8
1
  import { createHash } from "node:crypto";
9
2
  import type { AgentModeOption, ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk";
10
3
  import type { Context } from "@earendil-works/pi-ai";
@@ -14,7 +7,7 @@ import {
14
7
  type CursorPiToolBridgeRun,
15
8
  } from "./cursor-pi-tool-bridge.js";
16
9
  import { computeCursorContextFingerprint } from "./context.js";
17
- import { getCursorSessionScopeKey, onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
10
+ import { getCursorSessionScopeGeneration, getCursorSessionScopeKey } from "./cursor-session-scope.js";
18
11
  import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
19
12
  import { loadCursorSdk, type CursorSdkModule } from "./cursor-sdk-runtime.js";
20
13
 
@@ -88,9 +81,12 @@ export class SessionCursorAgentScopeClosedError extends Error {
88
81
  }
89
82
 
90
83
  function assertScopeAcceptsAcquire(scopeKey: string): void {
91
- if (terminalDisposedScopeKeys.has(scopeKey)) {
84
+ const terminalGeneration = terminalDisposedScopeGenerations.get(scopeKey);
85
+ if (terminalGeneration === undefined) return;
86
+ if (terminalGeneration >= getCursorSessionScopeGeneration(scopeKey)) {
92
87
  throw new SessionCursorAgentScopeClosedError();
93
88
  }
89
+ terminalDisposedScopeGenerations.delete(scopeKey);
94
90
  }
95
91
 
96
92
  function rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey: string, poolKey: string, error: unknown): void {
@@ -112,17 +108,9 @@ interface SessionCursorAgentCreateParams {
112
108
  createAgent?: CursorSdkModule["Agent"]["create"];
113
109
  }
114
110
 
115
- interface CursorSessionAgentExtensionApi {
116
- on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
117
- on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
118
- on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent>): void;
119
- on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
120
- on(event: "model_select", handler: ExtensionHandler<{ model: unknown }>): void;
121
- }
122
-
123
111
  const sessionAgentsByScope = new Map<string, SessionCursorAgentPoolEntry>();
124
112
  const invalidatedScopeKeys = new Set<string>();
125
- const terminalDisposedScopeKeys = new Set<string>();
113
+ const terminalDisposedScopeGenerations = new Map<string, number>();
126
114
  const scopeCreationGenerations = new Map<string, number>();
127
115
  const EMPTY_POOL_STATE: SessionCursorAgentPoolState = { status: "empty" };
128
116
  let nextSessionAgentInstanceId = 1;
@@ -194,7 +182,7 @@ async function disposePoolEntry(entry: SessionCursorAgentPoolEntry): Promise<voi
194
182
  async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?: boolean }): Promise<void> {
195
183
  invalidateScopeCreations(scopeKey);
196
184
  if (options?.terminal) {
197
- terminalDisposedScopeKeys.add(scopeKey);
185
+ terminalDisposedScopeGenerations.set(scopeKey, getCursorSessionScopeGeneration(scopeKey));
198
186
  }
199
187
  const entry = sessionAgentsByScope.get(scopeKey);
200
188
  invalidatedScopeKeys.delete(scopeKey);
@@ -416,7 +404,6 @@ export {
416
404
  planCursorSessionSend,
417
405
  type CursorSessionSendPlan,
418
406
  } from "./cursor-session-send-policy.js";
419
- export { shouldBootstrapCursorContext, shouldBootstrapCursorSend } from "./context.js";
420
407
 
421
408
  export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeKey()): void {
422
409
  invalidatedScopeKeys.add(scopeKey);
@@ -526,35 +513,10 @@ export async function disposeSessionCursorAgent(scopeKey: string = getCursorSess
526
513
  }
527
514
 
528
515
  export async function disposeAllSessionCursorAgents(): Promise<void> {
529
- const scopeKeys = [...new Set([...sessionAgentsByScope.keys(), ...terminalDisposedScopeKeys])];
516
+ const scopeKeys = [...new Set([...sessionAgentsByScope.keys(), ...terminalDisposedScopeGenerations.keys()])];
530
517
  await Promise.all(scopeKeys.map((scopeKey) => disposePoolEntryForScope(scopeKey, { terminal: true })));
531
518
  invalidatedScopeKeys.clear();
532
- terminalDisposedScopeKeys.clear();
533
- }
534
-
535
- export function registerCursorSessionAgent(_pi: CursorSessionAgentExtensionApi): void {
536
- onCursorSessionScopeKeyChange((previousScopeKey) => {
537
- void disposePoolEntryForScope(previousScopeKey, { terminal: true });
538
- });
539
- _pi.on("session_shutdown", async (event) => {
540
- if (event.reason === "reload") {
541
- await resetSessionCursorAgent();
542
- return;
543
- }
544
- await disposeSessionCursorAgent();
545
- });
546
- _pi.on("session_compact", () => {
547
- invalidateSessionAgent();
548
- });
549
- _pi.on("session_before_tree", () => {
550
- invalidateSessionAgent();
551
- });
552
- _pi.on("session_tree", async () => {
553
- await resetSessionCursorAgent();
554
- });
555
- _pi.on("model_select", () => {
556
- invalidateSessionAgent();
557
- });
519
+ terminalDisposedScopeGenerations.clear();
558
520
  }
559
521
 
560
522
  export const __testUtils = {
@@ -7,14 +7,17 @@ interface CursorSessionScopeExtensionApi {
7
7
  const ANONYMOUS_SESSION_SCOPE_KEY = "__anonymous__";
8
8
  const EPHEMERAL_SESSION_SCOPE_PREFIX = "__ephemeral__:";
9
9
 
10
- type CursorSessionScopeChangeHandler = (previousScopeKey: string) => void;
10
+ type CursorSessionScopeChangeHandler = (previousScopeKey: string) => Promise<void> | void;
11
11
 
12
12
  const state = {
13
13
  sessionCwd: process.cwd(),
14
14
  sessionFile: undefined as string | undefined,
15
15
  sessionId: undefined as string | undefined,
16
+ sessionGeneration: 0,
16
17
  };
17
18
 
19
+ const scopeGenerations = new Map<string, number>([[ANONYMOUS_SESSION_SCOPE_KEY, state.sessionGeneration]]);
20
+ let nextSessionGeneration = 1;
18
21
  let scopeChangeHandler: CursorSessionScopeChangeHandler | undefined;
19
22
 
20
23
  /**
@@ -34,7 +37,16 @@ export function getCursorSessionScopeKey(): string {
34
37
  return ANONYMOUS_SESSION_SCOPE_KEY;
35
38
  }
36
39
 
37
- export function getCursorSessionCwdFromScope(): string {
40
+ export function getCursorSessionScopeGeneration(scopeKey: string = getCursorSessionScopeKey()): number {
41
+ return scopeGenerations.get(scopeKey) ?? 0;
42
+ }
43
+
44
+ /**
45
+ * Pi session cwd when known; falls back to process.cwd() before session_start.
46
+ * Updated on session_start only until pi threads cwd into streamSimple—mid-session cwd
47
+ * changes without a new session_start event are not reflected here.
48
+ */
49
+ export function getCursorSessionCwd(): string {
38
50
  return state.sessionCwd;
39
51
  }
40
52
 
@@ -42,12 +54,19 @@ function setCursorSessionScope(cwd: string, sessionFile: string | undefined, ses
42
54
  state.sessionCwd = cwd;
43
55
  state.sessionFile = sessionFile;
44
56
  state.sessionId = sessionId;
57
+ state.sessionGeneration = nextSessionGeneration;
58
+ nextSessionGeneration += 1;
59
+ scopeGenerations.set(getCursorSessionScopeKey(), state.sessionGeneration);
45
60
  }
46
61
 
47
62
  function resetCursorSessionScope(): void {
48
63
  state.sessionCwd = process.cwd();
49
64
  state.sessionFile = undefined;
50
65
  state.sessionId = undefined;
66
+ state.sessionGeneration = 0;
67
+ nextSessionGeneration = 1;
68
+ scopeGenerations.clear();
69
+ scopeGenerations.set(ANONYMOUS_SESSION_SCOPE_KEY, state.sessionGeneration);
51
70
  }
52
71
 
53
72
  export function onCursorSessionScopeKeyChange(handler: CursorSessionScopeChangeHandler): void {
@@ -55,7 +74,7 @@ export function onCursorSessionScopeKeyChange(handler: CursorSessionScopeChangeH
55
74
  }
56
75
 
57
76
  export function registerCursorSessionScope(pi: CursorSessionScopeExtensionApi): void {
58
- pi.on("session_start", (_event, ctx) => {
77
+ pi.on("session_start", async (_event, ctx) => {
59
78
  const previousScopeKey = getCursorSessionScopeKey();
60
79
  setCursorSessionScope(
61
80
  ctx.cwd,
@@ -63,7 +82,7 @@ export function registerCursorSessionScope(pi: CursorSessionScopeExtensionApi):
63
82
  ctx.sessionManager?.getSessionId?.() ?? undefined,
64
83
  );
65
84
  if (previousScopeKey !== getCursorSessionScopeKey()) {
66
- scopeChangeHandler?.(previousScopeKey);
85
+ await scopeChangeHandler?.(previousScopeKey);
67
86
  }
68
87
  });
69
88
  }
@@ -11,16 +11,16 @@ export function resolveCursorSettingSources(raw?: string): SettingSource[] | und
11
11
  return resolveCursorSettingSourcesJs(raw) as SettingSource[] | undefined;
12
12
  }
13
13
 
14
- export function getEffectiveCursorSettingSources(raw: string | undefined = process.env[CURSOR_SETTING_SOURCES_ENV]): SettingSource[] | undefined {
14
+ export function getEffectiveCursorSettingSources(
15
+ raw: string | undefined = process.env[CURSOR_SETTING_SOURCES_ENV],
16
+ ): SettingSource[] | undefined {
15
17
  return resolveCursorSettingSources(raw);
16
18
  }
17
19
 
18
- export function cursorSettingSourcesLoadUserAgentsRules(settingSources: SettingSource[] | undefined): boolean {
20
+ export function cursorSettingSourcesIncludes(
21
+ settingSources: SettingSource[] | undefined,
22
+ source: Extract<SettingSource, "user" | "project">,
23
+ ): boolean {
19
24
  if (!settingSources?.length) return false;
20
- return settingSources.includes("all") || settingSources.includes("user");
21
- }
22
-
23
- export function cursorSettingSourcesLoadProjectAgentsRules(settingSources: SettingSource[] | undefined): boolean {
24
- if (!settingSources?.length) return false;
25
- return settingSources.includes("all") || settingSources.includes("project");
25
+ return settingSources.includes("all") || settingSources.includes(source);
26
26
  }
@@ -2,19 +2,15 @@ import type { Dirent } from "node:fs";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import { dirname, join, relative } from "node:path";
4
4
  import type {
5
- BeforeAgentStartEvent,
6
- BeforeAgentStartEventResult,
7
5
  BuildSystemPromptOptions,
8
6
  ExtensionAPI,
9
7
  ExtensionContext,
10
- ExtensionHandler,
11
- SessionStartEvent,
12
8
  Skill,
13
- TurnStartEvent,
14
9
  } from "@earendil-works/pi-coding-agent";
15
10
  import { Type } from "typebox";
16
11
  import { isCursorModel } from "./cursor-model.js";
17
- import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge-snapshot.js";
12
+ import { registerCursorModelLifecycle, type CursorModelLifecycleExtensionApi } from "./cursor-model-lifecycle.js";
13
+ import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge-env.js";
18
14
 
19
15
  export const CURSOR_ACTIVATE_SKILL_TOOL_NAME = "cursor_activate_skill";
20
16
  export const CURSOR_ACTIVATE_SKILL_MCP_NAME = "pi__cursor_activate_skill";
@@ -23,12 +19,7 @@ const AVAILABLE_SKILLS_SECTION_PATTERN = /\n\nThe following skills provide speci
23
19
  const MAX_SKILL_RESOURCES = 80;
24
20
  const RESOURCE_DIR_NAMES = ["scripts", "references", "assets"] as const;
25
21
 
26
- type CursorSkillToolExtensionApi = Pick<ExtensionAPI, "getActiveTools" | "registerTool" | "setActiveTools"> & {
27
- on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
28
- on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
29
- on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
30
- on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
31
- };
22
+ type CursorSkillToolExtensionApi = Pick<ExtensionAPI, "getActiveTools" | "registerTool" | "setActiveTools"> & CursorModelLifecycleExtensionApi;
32
23
 
33
24
  type CursorActivateSkillParams = {
34
25
  name?: string;
@@ -229,26 +220,28 @@ export function registerCursorSkillTool(pi: CursorSkillToolExtensionApi): void {
229
220
  syncCursorSkillToolForModel(pi, model);
230
221
  };
231
222
 
232
- pi.on("session_start", (_event, ctx) => {
233
- clearSkillsAndSync(ctx.model);
234
- });
235
- pi.on("model_select", (event) => {
236
- clearSkillsAndSync(event.model);
237
- });
238
- pi.on("turn_start", (_event, ctx) => {
239
- if (!isCursorModel(ctx.model)) setCurrentSkills([]);
240
- syncCursorSkillToolForModel(pi, ctx.model);
241
- });
242
- pi.on("before_agent_start", (event, ctx) => {
243
- if (isCursorModel(ctx.model)) {
244
- setCurrentSkills(event.systemPromptOptions?.skills);
245
- } else {
246
- setCurrentSkills([]);
247
- }
248
- syncCursorSkillToolForModel(pi, ctx.model);
249
- const resolved = resolveCursorSkillSystemPrompt(event.systemPrompt, ctx.model, event.systemPromptOptions);
250
- if (resolved === event.systemPrompt) return undefined;
251
- return { systemPrompt: resolved };
223
+ registerCursorModelLifecycle(pi, {
224
+ sessionStart: (_event, ctx) => {
225
+ clearSkillsAndSync(ctx.model);
226
+ },
227
+ modelSelect: (event) => {
228
+ clearSkillsAndSync(event.model);
229
+ },
230
+ turnStart: (_event, ctx) => {
231
+ if (!isCursorModel(ctx.model)) setCurrentSkills([]);
232
+ syncCursorSkillToolForModel(pi, ctx.model);
233
+ },
234
+ beforeAgentStart: (event, ctx) => {
235
+ if (isCursorModel(ctx.model)) {
236
+ setCurrentSkills(event.systemPromptOptions?.skills);
237
+ } else {
238
+ setCurrentSkills([]);
239
+ }
240
+ syncCursorSkillToolForModel(pi, ctx.model);
241
+ const resolved = resolveCursorSkillSystemPrompt(event.systemPrompt, ctx.model, event.systemPromptOptions);
242
+ if (resolved === event.systemPrompt) return undefined;
243
+ return { systemPrompt: resolved };
244
+ },
252
245
  });
253
246
  }
254
247
 
@@ -15,9 +15,12 @@ import {
15
15
  } from "./cursor-pi-tool-bridge-snapshot.js";
16
16
  import {
17
17
  CURSOR_SETTING_SOURCES_ENV,
18
- getEffectiveCursorSettingSources,
18
+ resolveCursorSettingSources,
19
19
  } from "./cursor-setting-sources.js";
20
20
  import { isCursorModel } from "./cursor-model.js";
21
+ import { registerCursorModelLifecycle } from "./cursor-model-lifecycle.js";
22
+ import { asRecord } from "./cursor-record-utils.js";
23
+ import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
21
24
  import { getCursorModelMetadata } from "./model-discovery.js";
22
25
 
23
26
  const FAST_ENTRY_TYPE = "cursor-fast-state";
@@ -58,6 +61,7 @@ let cliForceFast = false;
58
61
  let cliForceNoFast = false;
59
62
  let sessionCursorAgentMode: AgentModeOption | undefined;
60
63
  let cliCursorModeState: CursorCliModeState = { kind: "unset" };
64
+ const invalidCursorModeNotifiedSessionScopeKeys = new Set<string>();
61
65
 
62
66
  export function isCursorAgentMode(value: unknown): value is AgentModeOption {
63
67
  return value === "agent" || value === "plan";
@@ -69,13 +73,10 @@ export function parseCursorAgentMode(raw: unknown): AgentModeOption | undefined
69
73
  return isCursorAgentMode(mode) ? mode : undefined;
70
74
  }
71
75
 
72
- function isRecord(value: unknown): value is Record<string, unknown> {
73
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
- }
75
-
76
76
  function isCursorFastEntryData(value: unknown): value is CursorFastEntryData {
77
- if (!isRecord(value)) return false;
78
- return (typeof value.modelId === "string" || typeof value.baseModelId === "string") && typeof value.fast === "boolean";
77
+ const record = asRecord(value);
78
+ if (!record) return false;
79
+ return (typeof record.modelId === "string" || typeof record.baseModelId === "string") && typeof record.fast === "boolean";
79
80
  }
80
81
 
81
82
  function getCursorFastEntryModelId(data: CursorFastEntryData): string {
@@ -83,17 +84,19 @@ function getCursorFastEntryModelId(data: CursorFastEntryData): string {
83
84
  }
84
85
 
85
86
  function isCursorModeEntryData(value: unknown): value is CursorModeEntryData {
86
- return isRecord(value) && isCursorAgentMode(value.mode);
87
+ return isCursorAgentMode(asRecord(value)?.mode);
87
88
  }
88
89
 
89
90
  function parseCursorGlobalConfig(value: unknown): CursorGlobalConfig | undefined {
90
- if (!isRecord(value)) return undefined;
91
- const { fastDefaults } = value;
91
+ const record = asRecord(value);
92
+ if (!record) return undefined;
93
+ const { fastDefaults } = record;
92
94
  if (fastDefaults === undefined) return {};
93
- if (!isRecord(fastDefaults)) return undefined;
95
+ const fastDefaultsRecord = asRecord(fastDefaults);
96
+ if (!fastDefaultsRecord) return undefined;
94
97
  return {
95
98
  fastDefaults: Object.fromEntries(
96
- Object.entries(fastDefaults).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"),
99
+ Object.entries(fastDefaultsRecord).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"),
97
100
  ),
98
101
  };
99
102
  }
@@ -174,23 +177,38 @@ function formatInvalidCursorMode(raw: string): string {
174
177
  return `Invalid --cursor-mode "${raw}". Use "agent" or "plan".`;
175
178
  }
176
179
 
177
- export function getEffectiveCursorAgentMode(): AgentModeOption {
180
+ export type CursorAgentModeResolution =
181
+ | { kind: "valid"; mode: AgentModeOption }
182
+ | { kind: "invalid"; raw: string; message: string };
183
+
184
+ export function getStoredCursorAgentMode(): AgentModeOption {
185
+ return sessionCursorAgentMode ?? DEFAULT_CURSOR_AGENT_MODE;
186
+ }
187
+
188
+ export function resolveCursorAgentMode(): CursorAgentModeResolution {
178
189
  switch (cliCursorModeState.kind) {
179
190
  case "valid":
180
- return cliCursorModeState.mode;
191
+ return { kind: "valid", mode: cliCursorModeState.mode };
181
192
  case "invalid":
182
- throw new Error(cliCursorModeState.message);
193
+ return { kind: "invalid", raw: cliCursorModeState.raw, message: cliCursorModeState.message };
183
194
  case "unset":
184
- return sessionCursorAgentMode ?? DEFAULT_CURSOR_AGENT_MODE;
195
+ return { kind: "valid", mode: getStoredCursorAgentMode() };
185
196
  }
186
197
  }
187
198
 
199
+ export function getCursorProviderAgentModeOrThrow(): AgentModeOption {
200
+ const resolution = resolveCursorAgentMode();
201
+ if (resolution.kind === "invalid") throw new Error(resolution.message);
202
+ return resolution.mode;
203
+ }
204
+
188
205
  function formatCursorStatus(fast: boolean | undefined): string | undefined {
189
206
  const parts: string[] = [];
207
+ const modeResolution = resolveCursorAgentMode();
190
208
  if (fast === true) parts.push("fast");
191
- if (cliCursorModeState.kind === "invalid") {
209
+ if (modeResolution.kind === "invalid") {
192
210
  parts.push("mode invalid");
193
- } else if (getEffectiveCursorAgentMode() === "plan") {
211
+ } else if (modeResolution.mode === "plan") {
194
212
  parts.push("plan");
195
213
  }
196
214
  return parts.length > 0 ? `cursor ${parts.join(" · ")}` : undefined;
@@ -255,7 +273,7 @@ function persistCursorModePreference(pi: Pick<ExtensionAPI, "appendEntry">, mode
255
273
  }
256
274
  }
257
275
 
258
- function restoreCliCursorMode(raw: boolean | string | undefined, mode: ExtensionContext["mode"], notify: ExtensionContext["ui"]["notify"]): void {
276
+ function restoreCliCursorMode(raw: boolean | string | undefined): void {
259
277
  cliCursorModeState = { kind: "unset" };
260
278
  if (raw === undefined || raw === "" || raw === false) return;
261
279
  const parsed = parseCursorAgentMode(raw);
@@ -266,15 +284,19 @@ function restoreCliCursorMode(raw: boolean | string | undefined, mode: Extension
266
284
  const rawText = String(raw);
267
285
  const message = formatInvalidCursorMode(rawText);
268
286
  cliCursorModeState = { kind: "invalid", raw: rawText, message };
269
- if (mode === "tui") {
270
- notify(message, "error");
271
- return;
272
- }
273
- throw new Error(message);
287
+ }
288
+
289
+ function notifyInvalidCursorModeIfCursorActive(ctx: Pick<ExtensionContext, "hasUI" | "mode" | "ui">): void {
290
+ const modeResolution = resolveCursorAgentMode();
291
+ if (modeResolution.kind !== "invalid" || !ctx.hasUI || ctx.mode !== "tui") return;
292
+ const scopeKey = getCursorSessionScopeKey();
293
+ if (invalidCursorModeNotifiedSessionScopeKeys.has(scopeKey)) return;
294
+ invalidCursorModeNotifiedSessionScopeKeys.add(scopeKey);
295
+ ctx.ui.notify(modeResolution.message, "error");
274
296
  }
275
297
 
276
298
  function formatEffectiveCursorSettingSourcesLabel(raw: string | undefined = process.env[CURSOR_SETTING_SOURCES_ENV]): string {
277
- const effective = getEffectiveCursorSettingSources(raw);
299
+ const effective = resolveCursorSettingSources(raw);
278
300
  const effectiveLabel = effective === undefined ? "none" : effective.join(",");
279
301
  const rawLabel = raw?.trim() ? raw.trim() : "(unset → all)";
280
302
  return `${rawLabel} (effective: ${effectiveLabel})`;
@@ -395,10 +417,11 @@ export function registerCursorRuntimeControls(pi: CursorRuntimeControlsExtension
395
417
  const usage = "Usage: /cursor-mode agent|plan";
396
418
  const mode = parseCursorAgentMode(args);
397
419
  if (!args.trim()) {
398
- try {
399
- ctx.ui.notify(`Cursor mode is ${getEffectiveCursorAgentMode()}. ${usage}`, "info");
400
- } catch (error) {
401
- ctx.ui.notify(`${error instanceof Error ? error.message : String(error)} ${usage}`, "error");
420
+ const modeResolution = resolveCursorAgentMode();
421
+ if (modeResolution.kind === "invalid") {
422
+ ctx.ui.notify(`${modeResolution.message} ${usage}`, "error");
423
+ } else {
424
+ ctx.ui.notify(`Cursor mode is ${modeResolution.mode}. ${usage}`, "info");
402
425
  }
403
426
  return;
404
427
  }
@@ -429,28 +452,26 @@ export function registerCursorRuntimeControls(pi: CursorRuntimeControlsExtension
429
452
  },
430
453
  });
431
454
 
432
- pi.on("session_start", async (_event, ctx) => {
433
- globalFastPreferences = loadGlobalFastPreferences();
434
- cliForceFast = pi.getFlag("cursor-fast") === true;
435
- cliForceNoFast = pi.getFlag("cursor-no-fast") === true;
436
- restoreSessionFastPreferences(ctx);
437
- restoreSessionCursorMode(ctx);
438
- restoreCliCursorMode(pi.getFlag("cursor-mode"), ctx.mode, ctx.ui.notify.bind(ctx.ui));
439
- updateCursorStatus(ctx);
440
- });
441
-
442
- pi.on("model_select", async (event, ctx) => {
443
- updateCursorStatus(ctx, event.model);
444
- });
445
-
446
- pi.on("turn_start", async (_event, ctx) => {
447
- updateCursorStatus(ctx);
455
+ registerCursorModelLifecycle(pi, {
456
+ sessionStart: (_event, ctx) => {
457
+ globalFastPreferences = loadGlobalFastPreferences();
458
+ cliForceFast = pi.getFlag("cursor-fast") === true;
459
+ cliForceNoFast = pi.getFlag("cursor-no-fast") === true;
460
+ restoreSessionFastPreferences(ctx);
461
+ restoreSessionCursorMode(ctx);
462
+ restoreCliCursorMode(pi.getFlag("cursor-mode"));
463
+ },
464
+ sync: (ctx) => {
465
+ if (isCursorModel(ctx.model)) notifyInvalidCursorModeIfCursorActive(ctx);
466
+ updateCursorStatus(ctx);
467
+ },
448
468
  });
449
469
  }
450
470
 
451
471
  function resetCursorModeStateForTests(): void {
452
472
  sessionCursorAgentMode = undefined;
453
473
  cliCursorModeState = { kind: "unset" };
474
+ invalidCursorModeNotifiedSessionScopeKeys.clear();
454
475
  }
455
476
 
456
477
  export const __testUtils = {
@@ -1,9 +1,10 @@
1
1
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
2
2
  import { scrubSensitiveText } from "./cursor-sensitive-text.js";
3
3
  import { getCursorToolLifecycleLabelKind } from "./cursor-tool-presentation-registry.js";
4
- import { extractWebSearchQuery } from "./cursor-web-tool-activity.js";
5
- import { firstNonEmptyLine, getArray, getString, truncateArg } from "./cursor-transcript-utils.js";
6
- import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js";
4
+ import { extractWebSearchQuery } from "./cursor-web-tool-args.js";
5
+ import { getArray, getString } from "./cursor-record-utils.js";
6
+ import { firstNonEmptyLine, truncateArg } from "./cursor-transcript-utils.js";
7
+ import { classifyCursorToolVisibility, type CursorToolVisibility } from "./cursor-tool-visibility.js";
7
8
 
8
9
  /** Defer pending lifecycle lines so fast start+complete pairs coalesce into the completed replay card only. */
9
10
  export const CURSOR_TOOL_LIFECYCLE_DEFER_MS = 75;
@@ -12,8 +13,7 @@ export function isCursorToolLifecycleEligible(toolCall: unknown): boolean {
12
13
  return classifyCursorToolVisibility(toolCall).lifecycleEligible;
13
14
  }
14
15
 
15
- function getCursorToolLifecycleTitle(toolCall: unknown): string {
16
- const visibility = classifyCursorToolVisibility(toolCall);
16
+ function getCursorToolLifecycleTitle(visibility: CursorToolVisibility): string {
17
17
  return visibility.lifecycleTitle ?? `Cursor ${visibility.normalizedName}`;
18
18
  }
19
19
 
@@ -36,8 +36,10 @@ function scrubLifecycleDetail(value: string | undefined, apiKey?: string): strin
36
36
  return scrubbed;
37
37
  }
38
38
 
39
- export function buildCursorToolLifecycleLabel(toolCall: unknown, apiKey?: string): string | undefined {
40
- const visibility = classifyCursorToolVisibility(toolCall);
39
+ function buildCursorToolLifecycleLabelFromVisibility(
40
+ visibility: CursorToolVisibility,
41
+ apiKey?: string,
42
+ ): string | undefined {
41
43
  const args = visibility.args;
42
44
 
43
45
  switch (getCursorToolLifecycleLabelKind(visibility.normalizedKey)) {
@@ -79,8 +81,13 @@ export function buildCursorToolLifecycleLabel(toolCall: unknown, apiKey?: string
79
81
  }
80
82
  }
81
83
 
84
+ export function buildCursorToolLifecycleLabel(toolCall: unknown, apiKey?: string): string | undefined {
85
+ return buildCursorToolLifecycleLabelFromVisibility(classifyCursorToolVisibility(toolCall), apiKey);
86
+ }
87
+
82
88
  export function formatCursorToolLifecycleProgressText(toolCall: unknown, apiKey?: string): string | undefined {
83
- const label = buildCursorToolLifecycleLabel(toolCall, apiKey);
89
+ const visibility = classifyCursorToolVisibility(toolCall);
90
+ const label = buildCursorToolLifecycleLabelFromVisibility(visibility, apiKey);
84
91
  if (!label) return undefined;
85
- return `${getCursorToolLifecycleTitle(toolCall)}: ${label}\n`;
92
+ return `${getCursorToolLifecycleTitle(visibility)}: ${label}\n`;
86
93
  }