@yansigit/opencodex 2.36.1-dev.20260829.48 → 2.36.1

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 (94) hide show
  1. package/gui/dist/assets/{ApiKeys-LOOFiZfv.js → ApiKeys-CsmNyf4I.js} +1 -1
  2. package/gui/dist/assets/{Claude-BwKzpXe3.js → Claude-B4sHsGTD.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-B5qi9KeE.js → CodexSet-BuAQ4YQn.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B0sUef6W.js → FileIntegrationPage-BC65sEaS.js} +1 -1
  5. package/gui/dist/assets/{Grok-bsb4n-f4.js → Grok-DNJWlISf.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CSMwFDvM.js → Integrations-vGJshFZK.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-CTORdKJA.js → IntegrationsOverview-DYMkDFHE.js} +1 -1
  8. package/gui/dist/assets/{Logs-BWYbfELf.js → Logs-Coc7_RPP.js} +1 -1
  9. package/gui/dist/assets/{Models-CexEtdT1.js → Models-CKVT8Meb.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BapSFQnW.js → NumberStepper-Dug4wR8n.js} +1 -1
  11. package/gui/dist/assets/{Providers-BwUFeAgA.js → Providers-DyaCHRWL.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-CD8piq90.js → RestoreDialog-BpuvVU2w.js} +1 -1
  13. package/gui/dist/assets/{Startup-BgX731C2.js → Startup-DAUQqNEN.js} +1 -1
  14. package/gui/dist/assets/{Storage-Bgq7HphP.js → Storage-DfP95SlJ.js} +1 -1
  15. package/gui/dist/assets/{Subagents-ChxItbeF.js → Subagents-CMNsZLfk.js} +1 -1
  16. package/gui/dist/assets/{Usage-BkylzP50.js → Usage-ESFRfBMO.js} +1 -1
  17. package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
  18. package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
  19. package/gui/dist/assets/{data-surface-pCXChiBf.js → data-surface-CETFXqiA.js} +1 -1
  20. package/gui/dist/assets/{index-CNopOid3.js → index-DHQHBRhb.js} +3 -3
  21. package/gui/dist/assets/{model-display-CiUpg8T9.js → model-display-OBTJhQwT.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-b2jlS-On.js → provider-payload-CJRJUn7w.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
  24. package/gui/dist/assets/shared-DI18-uF7.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +3 -1
  27. package/src/adapters/base.ts +26 -0
  28. package/src/adapters/cursor/catalog.ts +541 -0
  29. package/src/adapters/cursor/cursor-errors.ts +15 -0
  30. package/src/adapters/cursor/discovery.ts +34 -41
  31. package/src/adapters/cursor/envelope-echo.ts +128 -0
  32. package/src/adapters/cursor/request-builder.ts +19 -12
  33. package/src/adapters/cursor/tool-definitions.ts +2 -1
  34. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  35. package/src/adapters/cursor.ts +21 -2
  36. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  37. package/src/adapters/google-antigravity-replay.ts +71 -2
  38. package/src/adapters/google.ts +19 -4
  39. package/src/adapters/kiro-constants.ts +12 -0
  40. package/src/adapters/kiro.ts +128 -11
  41. package/src/adapters/openai-chat.ts +16 -2
  42. package/src/adapters/openai-responses.ts +15 -2
  43. package/src/adapters/tool-catalog-nudge.ts +2 -1
  44. package/src/adapters/xai-web-search.ts +10 -14
  45. package/src/claude/outbound.ts +14 -3
  46. package/src/cli/access.ts +46 -3
  47. package/src/cli/account-api.ts +93 -16
  48. package/src/cli/account-extended.ts +262 -36
  49. package/src/cli/account-main.ts +12 -12
  50. package/src/cli/account.ts +40 -10
  51. package/src/cli/agent.ts +8 -1
  52. package/src/cli/capabilities-command.ts +94 -0
  53. package/src/cli/capabilities.ts +535 -0
  54. package/src/cli/claude-desktop.ts +31 -11
  55. package/src/cli/dispatch.ts +195 -27
  56. package/src/cli/doctor.ts +100 -1
  57. package/src/cli/help.ts +11 -2
  58. package/src/cli/index.ts +19 -3
  59. package/src/cli/inspect.ts +230 -0
  60. package/src/cli/observe.ts +11 -3
  61. package/src/cli/registry.ts +34 -2
  62. package/src/cli/runtime-api.ts +51 -7
  63. package/src/cli/status.ts +16 -0
  64. package/src/cli/storage.ts +234 -0
  65. package/src/cli/system-command.ts +16 -0
  66. package/src/cli/usage-report.ts +52 -2
  67. package/src/cli/version-skew.ts +46 -0
  68. package/src/codex/account-label.ts +21 -0
  69. package/src/codex/catalog/provider-fetch.ts +4 -0
  70. package/src/codex/transition-state.ts +12 -3
  71. package/src/compatibility/openai-responses.ts +9 -1
  72. package/src/generated/compatibility-version.json +94 -58
  73. package/src/integrations/ownership-policy.ts +24 -5
  74. package/src/integrations/ownership.ts +36 -2
  75. package/src/integrations/state.ts +40 -7
  76. package/src/integrations/writer.ts +21 -3
  77. package/src/lib/admin-secrets.ts +24 -0
  78. package/src/lib/errors.ts +25 -1
  79. package/src/lib/service-secrets.ts +15 -0
  80. package/src/oauth/store.ts +16 -6
  81. package/src/providers/label.ts +34 -1
  82. package/src/responses/turn-termination.ts +107 -0
  83. package/src/server/management/logs-usage-routes.ts +5 -27
  84. package/src/server/management/route-registry.ts +317 -0
  85. package/src/server/proxy-liveness.ts +27 -4
  86. package/src/server/request-log.ts +37 -17
  87. package/src/server/responses/core.ts +80 -4
  88. package/src/server/responses/policy-fallback.ts +1 -1
  89. package/src/service.ts +34 -0
  90. package/src/storage/policy-job.ts +14 -4
  91. package/src/storage/policy.ts +79 -33
  92. package/src/usage/log.ts +32 -6
  93. package/src/usage/summary.ts +22 -34
  94. package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
@@ -5,6 +5,7 @@ import {
5
5
  cursorWireModelIdWithEffort,
6
6
  CURSOR_THINKING_MODEL_IDS,
7
7
  } from "./effort-map";
8
+ import { parseCursorVariantId } from "./catalog";
8
9
 
9
10
  export interface CursorModelInfo {
10
11
  id: string;
@@ -76,9 +77,16 @@ function stripCursorWirePrefix(id: string): string {
76
77
  * ordinary `{base}-{effort}` form, or Cursor's current `{base-without-fast}-{effort}-fast` form.
77
78
  */
78
79
  export function isCursorModelAvailableForAccount(modelId: string, liveIds: readonly string[]): boolean {
80
+ // Umbrella matching (devlog 260828_cursor_umbrella_catalog): a live suffix
81
+ // id counts toward its BASE — any variant dimension (thinking/fast/effort)
82
+ // proves the account can reach the umbrella. Unknown ids fall back to the
83
+ // legacy exact/suffix comparison so non-cataloged rows keep matching.
84
+ const parsedTarget = parseCursorVariantId(modelId);
79
85
  return liveIds.some(raw => {
80
86
  const id = stripCursorWirePrefix(raw);
81
87
  if (id === modelId) return true;
88
+ const parsedLive = parseCursorVariantId(id);
89
+ if (parsedLive.known && parsedTarget.known && parsedLive.baseId === parsedTarget.baseId) return true;
82
90
  for (const effort of CANONICAL_EFFORT_SUFFIXES) {
83
91
  if (
84
92
  id === `${modelId}-${effort}` ||
@@ -244,15 +252,13 @@ export function filterCursorConfiguredModelsByLiveDiscovery<T extends { id: stri
244
252
 
245
253
  /**
246
254
  * Models GetUsableModels advertises but whose every Run returns not_found (catalog honesty,
247
- * devlog 260826_cursor_responses_gap 060). Live probes 2026-08-26: cursor/claude-opus-5 failed
248
- * 100% ("Cursor Connect error not_found") while its -fast and -thinking siblingsseparate
249
- * wire families succeed. Quarantined here, in the shared filter, so live, cached, stale, and
250
- * static serving paths all agree. Custom user provider overrides are not routed through this
251
- * canonical seed and stay untouched.
255
+ * devlog 260826_cursor_responses_gap 060). The claude-opus-5 REGULAR wire family is the known
256
+ * case (probes 2026-08-26: 100% not_found while -fast/-thinking succeed)under the umbrella
257
+ * catalog (devlog 260828) that quarantine moved to the RESOLVER level: the capability marks the
258
+ * regular VARIANT quarantined, the bare slug routes the healthy thinking variant, and the base
259
+ * row stays in the seed. This row-level set stays for future whole-base quarantines.
252
260
  */
253
- export const CURSOR_KNOWN_UNCALLABLE_MODEL_IDS: ReadonlySet<string> = new Set([
254
- "claude-opus-5",
255
- ]);
261
+ export const CURSOR_KNOWN_UNCALLABLE_MODEL_IDS: ReadonlySet<string> = new Set([]);
256
262
 
257
263
  export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([
258
264
  // Context windows and the model lineup mirror Cursor's public models/pricing docs plus the jawcode
@@ -265,26 +271,26 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
265
271
  // gemini/grok/kimi-k2.7/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker).
266
272
  ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })),
267
273
 
268
- { id: "claude-sonnet-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
274
+ // Umbrella seed (devlog 260828_cursor_umbrella_catalog): one row per BASE
275
+ // model. Thinking merges into the base (the resolver routes the thinking
276
+ // variant); fast / thinking-fast / -1m stay routable as aliases but add no
277
+ // rows. Windows follow CURSOR_CAPABILITIES where the base is cataloged.
278
+ { id: "claude-sonnet-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
269
279
  { id: "claude-4-sonnet", contextWindow: CONTEXT_200K },
270
280
  { id: "claude-4-sonnet-1m", contextWindow: CONTEXT_1M },
271
281
  { id: "claude-4.5-haiku", contextWindow: CONTEXT_200K },
272
282
  { id: "claude-4.5-sonnet", contextWindow: CONTEXT_200K },
273
283
  { id: "claude-4.5-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
274
- { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
275
- { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
276
- { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
277
- // Opus Fast families: live GetUsableModels (260822) lists ONLY effort-suffixed wire ids
278
- // ({base-without-fast}-{effort}-fast; the bare id returns not_found), so every entry
279
- // carries a tier picker. Live-verified: claude-opus-4-8-high-fast completed a turn.
280
- // Tiers per the 260822 dump (devlog 260822_senpi_cursor_transfer/300).
281
- { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
282
- { id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
283
- { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
284
- // claude-opus-5 (bare) removed from the seed: GetUsableModels lists it but every Run returns
285
- // not_found (quarantined via CURSOR_KNOWN_UNCALLABLE_MODEL_IDS; -fast/-thinking families stay).
286
- { id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
287
- { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
284
+ { id: "claude-4.6-opus", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
285
+ { id: "claude-4.6-sonnet", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
286
+ { id: "claude-opus-4-7", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
287
+ { id: "claude-opus-4-8", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
288
+ // claude-opus-5: regular variant is quarantined (not_found on every Run) but
289
+ // the umbrella row routes the THINKING variant, which is live — so the base
290
+ // row returns to the seed under the umbrella (resolver never sends the
291
+ // quarantined regular wire id for the bare slug).
292
+ { id: "claude-opus-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
293
+ { id: "claude-fable-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
288
294
 
289
295
  { id: "composer-1", contextWindow: CONTEXT_200K },
290
296
  { id: "composer-2.5", contextWindow: CONTEXT_200K },
@@ -301,17 +307,6 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
301
307
  { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true },
302
308
  { id: "gemini-3.7-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true },
303
309
 
304
- // Explicit-thinking variants (260825 live roster). Exposed as first-class ids the same way the
305
- // Opus Fast families were in 831810c13: `isCursorModelAvailableForAccount` matches a base id
306
- // against `{base}`, `{base}-{effort}` and the family's wire form, and none of those ever
307
- // matched a `-thinking` id, so every one of these was invisible in the routed catalog.
308
- // Suffix ORDER differs per family; `cursorWireModelIdWithEffort` owns that mapping.
309
- ...CURSOR_THINKING_MODEL_IDS.map(id => ({
310
- id,
311
- contextWindow: CONTEXT_200K,
312
- supportsReasoningEffort: cursorModelHasEffortTiers(id),
313
- })),
314
-
315
310
  { id: "gpt-5-codex", contextWindow: CONTEXT_272K },
316
311
  { id: "gpt-5-fast", contextWindow: CONTEXT_272K },
317
312
  { id: "gpt-5-mini", contextWindow: CONTEXT_272K },
@@ -344,17 +339,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
344
339
  { id: "kimi-k2.7-code", contextWindow: CONTEXT_262K },
345
340
  // kimi-k3: cursor.com/docs/models/kimi-k3; account-verified via GetUsableModels (2026-07-28) —
346
341
  // ships only as effort-suffixed kimi-k3-{low,high,max}, so the tier picker is exposed.
347
- { id: "kimi-k3", contextWindow: CONTEXT_262K, supportsReasoningEffort: true },
348
- // kimi-k3-1m: synthetic ultra/Max-Mode picker variant (CURSOR_ULTRA_1M_MODEL_IDS) wire sends
349
- // kimi-k3-<effort> with maxMode=true; 1M context user-verified live on the Ultra plan
350
- // (devlog 260826_cursor_responses_gap/025). inferCursorContextWindow maps "1m" ids to 1M.
351
- { id: "kimi-k3-1m", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
342
+ // kimi-k3 folds the old synthetic kimi-k3-1m row into the umbrella: the base
343
+ // is maxModeVerified (user-verified 1M on the Ultra plan, devlog 260826/025),
344
+ // so the ultra effort rung arms Max Mode on the wire and the separate picker
345
+ // row is gone. cursor/kimi-k3-1m stays routable as an alias.
346
+ { id: "kimi-k3", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
352
347
 
353
348
  { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true },
354
- { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true },
355
349
  // 260813 preemptive: grok-4.6 seeded ahead of Cursor's lineup update (mirrors grok-4.5).
356
350
  { id: "grok-4.6", contextWindow: 500_000, supportsReasoningEffort: true },
357
- { id: "grok-4.6-fast", contextWindow: 500_000, supportsReasoningEffort: true },
358
351
  ]);
359
352
 
360
353
  export function cursorModelIds(models: readonly CursorModelInfo[] = CURSOR_STATIC_MODELS): string[] {
@@ -14,6 +14,14 @@
14
14
 
15
15
  const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const;
16
16
  const MAX_SNIFF_BYTES = 40;
17
+ /** Mid-stream observer: max leading whitespace on a line before matching disarms. */
18
+ const MAX_MIDSTREAM_LINE_INDENT = 128;
19
+ /** Mid-stream observer: post-marker window watched for call-id corruption. */
20
+ const MIDSTREAM_CORRUPTION_WINDOW = 512;
21
+ /** Mid-stream observer: cumulative scan cap (UTF-16 code units, checked between feeds). */
22
+ export const MAX_MIDSTREAM_SCAN_LENGTH = 512 * 1024;
23
+ /** Mid-stream observer: findings retained per turn. */
24
+ const MAX_MIDSTREAM_FINDINGS = 8;
17
25
  const MAX_ROUTING_COMMENTARY_BYTES = 512;
18
26
  /** Aggregate quarantine cap: past this, flush and disarm. */
19
27
  const MAX_HOLD_BYTES = 8 * 1024;
@@ -43,6 +51,126 @@ export type EchoSnifferDecision =
43
51
  | { kind: "flush" }
44
52
  | { kind: "echo"; marker: string };
45
53
 
54
+ export interface MidstreamEchoFinding {
55
+ marker: string;
56
+ /** UTF-16 offset of the marker's line start within the turn's full text. */
57
+ offset: number;
58
+ callIdCorrupt: boolean;
59
+ }
60
+
61
+ /**
62
+ * Diagnostic-only mid-stream envelope-echo observer (devlog 260828 F1/F2).
63
+ *
64
+ * The prefix sniffer only watches the first ~40 bytes of a turn, but live
65
+ * probing caught grok-4.6 echoing "[Tool Result]" envelope blocks in the
66
+ * MIDDLE of an agent message — after legitimate leading text — one of them
67
+ * carrying a whitespace-spliced call-id ("fc_x mar-y" instead of "fc_x-y").
68
+ * Deltas at that point have already reached the client, so this observer
69
+ * never throws and never withholds output: it records findings so the
70
+ * adapter can emit a structured diagnostic at turn end. Only fixed marker
71
+ * enums, numeric offsets, and corruption booleans are retained — never
72
+ * content bytes.
73
+ */
74
+ export class CursorMidstreamEchoObserver {
75
+ private lineBuffer = "";
76
+ private lineStartOffset = 0;
77
+ private totalLength = 0;
78
+ private disarmed = false;
79
+ private lineDisarmed = false;
80
+ private corruptionWatch: { finding: MidstreamEchoFinding; remaining: number; window: string } | undefined;
81
+ private readonly recorded: MidstreamEchoFinding[] = [];
82
+
83
+ feed(textDelta: string): void {
84
+ if (this.disarmed && !this.corruptionWatch) return;
85
+ let index = 0;
86
+ while (index < textDelta.length) {
87
+ const newline = textDelta.indexOf("\n", index);
88
+ const segment = newline === -1 ? textDelta.slice(index) : textDelta.slice(index, newline);
89
+ if (this.corruptionWatch) this.watchCorruption(segment + (newline === -1 ? "" : "\n"));
90
+ if (!this.disarmed && !this.lineDisarmed && segment.length > 0) {
91
+ this.lineBuffer += segment;
92
+ if (this.lineBuffer.length > MAX_MIDSTREAM_LINE_INDENT + 32) {
93
+ // Bound per-line work: nothing beyond the indent cap + longest marker can match.
94
+ this.lineDisarmed = !this.lineMatchesPrefixSoFar();
95
+ this.lineBuffer = this.lineBuffer.slice(0, MAX_MIDSTREAM_LINE_INDENT + 32);
96
+ }
97
+ this.checkLine();
98
+ }
99
+ if (newline === -1) break;
100
+ this.lineBuffer = "";
101
+ this.lineDisarmed = false;
102
+ this.lineStartOffset = this.totalLength + newline + 1;
103
+ index = newline + 1;
104
+ }
105
+ this.totalLength += textDelta.length;
106
+ if (this.totalLength > MAX_MIDSTREAM_SCAN_LENGTH) this.disarmed = true;
107
+ }
108
+
109
+ findings(): readonly MidstreamEchoFinding[] {
110
+ if (this.corruptionWatch) {
111
+ this.settleCorruption();
112
+ }
113
+ return this.recorded;
114
+ }
115
+
116
+ private lineMatchesPrefixSoFar(): boolean {
117
+ const probe = this.lineBuffer.replace(/^[ \t]*/, "");
118
+ return ECHO_MARKERS.some(marker => probe.startsWith(marker) || marker.startsWith(probe));
119
+ }
120
+
121
+ private checkLine(): void {
122
+ const indentMatch = /^[ \t]*/.exec(this.lineBuffer);
123
+ const indent = indentMatch ? indentMatch[0].length : 0;
124
+ if (indent > MAX_MIDSTREAM_LINE_INDENT) {
125
+ this.lineDisarmed = true;
126
+ return;
127
+ }
128
+ const probe = this.lineBuffer.slice(indent);
129
+ for (const marker of ECHO_MARKERS) {
130
+ if (probe.startsWith(marker)) {
131
+ // The prefix sniffer owns the very start of the turn; only offsets past
132
+ // its window count as mid-stream.
133
+ if (this.lineStartOffset === 0) {
134
+ this.lineDisarmed = true;
135
+ return;
136
+ }
137
+ const finding: MidstreamEchoFinding = {
138
+ marker,
139
+ offset: this.lineStartOffset,
140
+ callIdCorrupt: false,
141
+ };
142
+ this.corruptionWatch = { finding, remaining: MIDSTREAM_CORRUPTION_WINDOW, window: "" };
143
+ this.lineDisarmed = true;
144
+ return;
145
+ }
146
+ }
147
+ if (!ECHO_MARKERS.some(marker => marker.startsWith(probe)) && probe.length > 0) {
148
+ this.lineDisarmed = true;
149
+ }
150
+ }
151
+
152
+ private watchCorruption(text: string): void {
153
+ const watch = this.corruptionWatch;
154
+ if (!watch) return;
155
+ const take = Math.min(watch.remaining, text.length);
156
+ watch.window += text.slice(0, take);
157
+ watch.remaining -= take;
158
+ if (watch.remaining <= 0) this.settleCorruption();
159
+ }
160
+
161
+ private settleCorruption(): void {
162
+ const watch = this.corruptionWatch;
163
+ if (!watch) return;
164
+ const window = watch.window;
165
+ watch.finding.callIdCorrupt =
166
+ /fc_[0-9a-f]+[ \t]+mar-/.test(window)
167
+ || /call_id: \S+[ \t]+\S+_0\b/.test(window);
168
+ if (this.recorded.length < MAX_MIDSTREAM_FINDINGS) this.recorded.push(watch.finding);
169
+ // Window text is discarded here; only booleans/offsets survive.
170
+ this.corruptionWatch = undefined;
171
+ }
172
+ }
173
+
46
174
  /**
47
175
  * Incremental envelope-prefix sniffer. Leading whitespace is tolerated so a
48
176
  * marker copied after a newline is still caught.
@@ -12,7 +12,7 @@ import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequ
12
12
  import { cursorCheckpointModelAffinityId, cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
13
13
  import { cursorUltraBaseModelId } from "./discovery";
14
14
  import { decodeCursorCallId } from "./call-id";
15
- import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map";
15
+ import { cursorGrokFastSelection, resolveCursorSelection } from "./catalog";
16
16
  import {
17
17
  cursorMcpToolEncodedSize,
18
18
  cursorMcpToolsEncodedSize,
@@ -192,20 +192,22 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
192
192
  routingLevel?: CursorRoutingLevel;
193
193
  maxMode?: boolean;
194
194
  } {
195
- // Synthetic ultra (-1m) picker rows resolve to their wire base with Max Mode on
196
- // (devlog 260826 070); the marker never reaches the wire.
197
- const ultraBase = cursorUltraBaseModelId(modelId);
198
- const selection = cursorWireModelSelection(ultraBase ?? modelId);
199
- const maxMode = ultraBase !== undefined ? { maxMode: true } : {};
195
+ // Router ids (auto / auto-<level>) keep their dedicated wire selection.
196
+ const selection = cursorWireModelSelection(modelId);
197
+ if (selection.routingLevel !== undefined || selection.modelId === "default") return selection;
198
+ // Umbrella catalog resolution (devlog 260828_cursor_umbrella_catalog): one
199
+ // resolver owns effort composition, variant dimensions, the synthetic -1m
200
+ // marker (ultra -> Max Mode, evidence-gated), and the cursor- wire prefix.
200
201
  const id = selection.modelId;
201
- const suffix = cursorEffortSuffix(id, reasoning);
202
- if ((id === "grok-4.5-fast" || id === "grok-4.6-fast") && suffix) {
202
+ // Grok Fast stays parameterized: current Cursor clients send the base id
203
+ // plus effort/fast parameters instead of the flattened -fast id.
204
+ const grokFast = cursorGrokFastSelection(id, reasoning);
205
+ if (grokFast) {
203
206
  return {
204
207
  ...selection,
205
- ...maxMode,
206
- modelId: id.slice(0, -"-fast".length),
208
+ modelId: grokFast.wireBaseId,
207
209
  requestedModelParameters: [
208
- { id: "effort", value: suffix },
210
+ { id: "effort", value: grokFast.effort },
209
211
  { id: "fast", value: "true" },
210
212
  ],
211
213
  };
@@ -217,7 +219,12 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
217
219
  requestedModelParameters: [{ id: "fast", value: "false" }],
218
220
  };
219
221
  }
220
- return { ...selection, ...maxMode, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id };
222
+ const resolved = resolveCursorSelection(id, reasoning);
223
+ return {
224
+ ...selection,
225
+ ...(resolved.maxMode ? { maxMode: true } : {}),
226
+ modelId: resolved.wireId,
227
+ };
221
228
  }
222
229
 
223
230
  function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined {
@@ -3,6 +3,7 @@ import { ValueSchema } from "@bufbuild/protobuf/wkt";
3
3
  import type { OcxRequestOptions, OcxTool } from "../../types";
4
4
  import { namespacedToolName, toolChoiceAliases } from "../../types";
5
5
  import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from "./gen/agent_pb";
6
+ import { CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize";
6
7
 
7
8
  export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses";
8
9
  export const CODEX_EXEC_COMMAND_TOOL = "exec_command";
@@ -685,7 +686,7 @@ export function buildCursorToolGuidanceSystemNote(
685
686
  ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.<name>(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description and the isolate global \`ALL_TOOLS\` (not \`tools.ALL_TOOLS\`) for helpers this turn provides; absence from the top-level catalog or from \`exec\`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\` or \`shell_command\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}. Nested \`tools.apply_patch(input)\` is host-executed: the string must begin exactly with \`*** Begin Patch\` and end with \`*** End Patch\` (no trailing \`***\` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated \`*** Begin Patch ***\` envelope is rejected by Codex before the file is touched.`
686
687
  : undefined,
687
688
  codeMode
688
- ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers."
689
+ ? CODE_MODE_RESULT_ECHO_SENTENCE + " There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers."
689
690
  : undefined,
690
691
  codeMode
691
692
  ? 'When commands require network access, file writes outside workspace, or fail due to sandbox/permission restrictions, pass `sandbox_permissions: "require_escalated"` and a clear `justification: "..."` to `tools.exec_command`.'
@@ -9,6 +9,23 @@
9
9
  * dropping images oldest-first — see toolCallStep in protobuf-request.ts).
10
10
  */
11
11
 
12
+ import {
13
+ EMPTY_EXEC_OUTPUT_MESSAGE,
14
+ EMPTY_EXEC_OUTPUT_REGEX,
15
+ FAILED_EXEC_OUTPUT_MESSAGE,
16
+ FAILED_EXEC_OUTPUT_REGEX,
17
+ isCodexExecBridgeTool,
18
+ } from "../exec-tool-result-normalize";
19
+
20
+ /**
21
+ * Cursor treats a failed-but-empty wrapper as an empty result too (its Computer Use branch marks
22
+ * such results `isError` separately). The shared success regex deliberately excludes
23
+ * `Script failed`, so restore that arm here rather than widening the shared one.
24
+ */
25
+ function isEmptyOrFailedExecWrapper(text: string): boolean {
26
+ return EMPTY_EXEC_OUTPUT_REGEX.test(text) || FAILED_EXEC_OUTPUT_REGEX.test(text);
27
+ }
28
+
12
29
  const COMPUTER_USE_TOOL_NAMES = new Set([
13
30
  "node_repl",
14
31
  "node_repl__js",
@@ -29,31 +46,6 @@ function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string):
29
46
  return lower.startsWith("mcp__node_repl") || lower.startsWith("mcp__computer_use");
30
47
  }
31
48
 
32
- /**
33
- * Codex exec / shell-bridge tool names (flat and MCP-prefixed display aliases). An empty result
34
- * here is almost always a code-mode cell that never called text()/notify() — the cursor model
35
- * reads the blank [tool_result], concludes prior results were lost, and spirals into
36
- * re-orientation retries (devlog 260826_cursor_responses_gap, live subagent transcripts).
37
- */
38
- function isCodexExecBridgeTool(toolName?: string, toolNamespace?: string): boolean {
39
- if (toolNamespace && toolNamespace.includes("opencodex-responses")) return true;
40
- if (!toolName) return false;
41
- const lower = toolName.toLowerCase();
42
- return (
43
- lower === "exec"
44
- || lower === "exec_command"
45
- || lower === "shell_command"
46
- // Codex CLI/desktop native tool names: the multi-round "이전 출력이 비어 있어 처음부터"
47
- // restart loop reproduced via codex exec because `shell` was not in this set
48
- // (devlog 260826 gap-8 QA round 2).
49
- || lower === "shell"
50
- || lower === "local_shell"
51
- || lower === "container.exec"
52
- || lower.startsWith("mcp_opencodex-responses_")
53
- || lower.startsWith("mcp__opencodex-responses__")
54
- );
55
- }
56
-
57
49
  /** Failure states the Computer Use / node_repl runtime reports as PLAIN TEXT inside a non-error result. */
58
50
  const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string }> = [
59
51
  {
@@ -90,9 +82,6 @@ const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string
90
82
  },
91
83
  ];
92
84
 
93
- /** Matches exec wrappers whose only payload is an empty-output marker. */
94
- const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Script failed|Command finished|Execution finished)[^\n]*\n+)?(?:Wall time[^\n]*\n+)?(?:Output:\s*)?(?:<empty>)?\s*$/;
95
-
96
85
  export interface NormalizedToolResultText {
97
86
  text: string;
98
87
  isError: boolean;
@@ -114,16 +103,19 @@ export function normalizeCursorToolResultText(
114
103
  ): NormalizedToolResultText {
115
104
  const isError = options.isError === true;
116
105
  const computerUse = isNodeReplOrComputerUseTool(options.toolName, options.toolNamespace);
117
- if (computerUse && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim())) {
106
+ if (computerUse && isEmptyOrFailedExecWrapper(text.trim())) {
118
107
  return {
119
108
  text: "[empty output: the tool ran but produced no stdout or return value. Verify application state with get_app_state, or make the script emit output.]",
120
109
  isError: true,
121
110
  changed: true,
122
111
  };
123
112
  }
124
- if (isCodexExecBridgeTool(options.toolName, options.toolNamespace) && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim())) {
113
+ if (isCodexExecBridgeTool(options.toolName, options.toolNamespace) && isEmptyOrFailedExecWrapper(text.trim())) {
125
114
  return {
126
- text: "[empty output: the exec cell completed but emitted nothing. This is NOT lost context and NOT a blocked tool in code mode call text(...) or notify(...) on any value you need to see (a bare await tools.exec_command(...) is not echoed automatically); in shell mode the command simply printed nothing. Do not re-run the same call expecting different output.]",
115
+ // A `Script failed` wrapper is empty but NOT a success: reporting it as an empty success
116
+ // would erase the only failure signal. Text classification stays separate from Cursor's
117
+ // isError policy, which the Computer Use branch above owns.
118
+ text: FAILED_EXEC_OUTPUT_REGEX.test(text.trim()) ? FAILED_EXEC_OUTPUT_MESSAGE : EMPTY_EXEC_OUTPUT_MESSAGE,
127
119
  isError: false,
128
120
  changed: true,
129
121
  };
@@ -41,6 +41,7 @@ import {
41
41
  CURSOR_ECHO_RETRY_CONTINUATION_TEXT,
42
42
  CURSOR_ROUTING_COMMENTARY_RETRY_TEXT,
43
43
  CursorEnvelopeEchoSniffer,
44
+ CursorMidstreamEchoObserver,
44
45
  CursorRoutingCommentaryError,
45
46
  CursorRoutingCommentarySniffer,
46
47
  CursorToolResultEchoError,
@@ -256,6 +257,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
256
257
  isCursorExternalWireModel(activeRequest.modelId)
257
258
  && (_parsed.context.messages ?? []).some(message => message.role === "toolResult");
258
259
  const echoSniffer = armEchoSniffer ? new CursorEnvelopeEchoSniffer() : undefined;
260
+ // Mid-stream observer (devlog 260828 F1/F2): diagnostic-only; armed with the
261
+ // prefix sniffer because both fire on flattened tool-result replay priming.
262
+ const midstreamObserver = armEchoSniffer ? new CursorMidstreamEchoObserver() : undefined;
259
263
  const armRoutingCommentarySniffer =
260
264
  isCursorExternalWireModel(activeRequest.modelId)
261
265
  && (
@@ -266,10 +270,16 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
266
270
  ? new CursorRoutingCommentarySniffer()
267
271
  : undefined;
268
272
  let guardHeld: AdapterEvent[] = [];
273
+ // Exactly-once observation: every client-bound text delta passes through here
274
+ // exactly once — held deltas only on release, ordinary deltas at emit time.
275
+ const emitTextObserved = (event: AdapterEvent): void => {
276
+ if (event.type === "text_delta") midstreamObserver?.feed(event.text);
277
+ emit(event);
278
+ };
269
279
  const releaseGuardHeld = () => {
270
280
  for (const held of guardHeld) {
271
281
  if (held.type !== "heartbeat") emittedOutput = true;
272
- emit(held);
282
+ emitTextObserved(held);
273
283
  }
274
284
  guardHeld = [];
275
285
  };
@@ -348,6 +358,15 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
348
358
  }
349
359
  if (event.type !== "heartbeat") emittedOutput = true;
350
360
  if (event.type === "done") {
361
+ for (const finding of midstreamObserver?.findings() ?? []) {
362
+ debugProviderDiagnostic("cursor", "midstream-envelope-echo", {
363
+ wireModel: activeRequest.modelId,
364
+ conversationHash: activeRequest.conversationId.slice(0, 16),
365
+ marker: finding.marker,
366
+ offset: finding.offset,
367
+ callIdCorrupt: finding.callIdCorrupt,
368
+ });
369
+ }
351
370
  commitCapturedCheckpoint(activeRequest);
352
371
  const inheritedCursor = _parsed._providerContinuation?.cursor;
353
372
  const isolatedOrCompaction =
@@ -367,7 +386,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
367
386
  : undefined;
368
387
  emit(providerState ? { ...event, providerState } : event);
369
388
  } else {
370
- emit(event);
389
+ emitTextObserved(event);
371
390
  }
372
391
  }
373
392
  },
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Provider-neutral empty-exec-output normalization.
3
+ *
4
+ * A code-mode `exec` cell that never calls `text()`/`notify()` returns nothing: the last
5
+ * expression value is NOT echoed automatically. The routed model reads a blank tool result,
6
+ * concludes its earlier output was lost, and burns turns re-running the same call or restarting
7
+ * the task from scratch. Naming that state explicitly is what breaks the loop.
8
+ *
9
+ * This module owns the shared detection so every adapter reports the same thing. Cursor keeps its
10
+ * own wrapper (`normalizeCursorToolResultText`) for Computer Use precedence and isError policy;
11
+ * Kiro consumes this helper directly.
12
+ */
13
+
14
+ /**
15
+ * Matches exec wrappers whose only payload is an empty-output marker.
16
+ *
17
+ * `Script failed` is deliberately NOT in this set. A failed cell with no captured output is still
18
+ * a FAILURE, and the success guidance below ("not a blocked tool", "do not re-run") would erase the
19
+ * only signal that anything went wrong — reachable through Responses history, where
20
+ * `function_call_output` is parsed with `isError: false`. Cursor keeps its own broader regex for
21
+ * Computer Use, where a failed wrapper is separately marked `isError`.
22
+ */
23
+ export const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)[^\n]*\n+)?(?:Wall time[^\n]*\n+)?(?:Output:\s*)?(?:<empty>)?\s*$/;
24
+
25
+ /** Wrapper for a cell that FAILED without emitting output: empty, but not a success. */
26
+ export const FAILED_EXEC_OUTPUT_REGEX = /^Script failed[^\n]*\n*(?:Wall time[^\n]*\n*)?(?:Output:\s*)?(?:<empty>)?\s*$/;
27
+
28
+ /** Guidance for a failed cell whose output was empty: the failure must survive normalization. */
29
+ export const FAILED_EXEC_OUTPUT_MESSAGE =
30
+ "[exec failed with no captured output: the cell raised before emitting anything. This is a real failure, not an empty success — inspect the call for a thrown error or syntax problem before retrying.]";
31
+
32
+ /**
33
+ * The guidance itself. Worded to close all three wrong conclusions a model draws from a blank
34
+ * result: that context was lost, that the tool is blocked, and that retrying will differ.
35
+ */
36
+ export const EMPTY_EXEC_OUTPUT_MESSAGE =
37
+ "[empty output: the exec cell completed but emitted nothing. This is NOT lost context and NOT a blocked tool — in code mode call text(...) or notify(...) on any value you need to see (a bare await tools.exec_command(...) is not echoed automatically); in shell mode the command simply printed nothing. Do not re-run the same call expecting different output.]";
38
+
39
+ /**
40
+ * The SAME rule stated BEFORE the first call, for the code-mode tool-catalog nudge.
41
+ *
42
+ * `EMPTY_EXEC_OUTPUT_MESSAGE` above is a repair: it fires only after a model has already spent a
43
+ * call and read a blank result. That recovers the turn but cannot prevent the wasted round trip,
44
+ * and the model still has to guess whether its command failed or its output was merely dropped.
45
+ * Stating the echo rule up front removes the failure instead of explaining it afterwards.
46
+ *
47
+ * Kept beside the recovery text on purpose: the two are one pair guarding one defect, and wording
48
+ * that drifts apart is how a model gets told two different things about the same isolate.
49
+ */
50
+ export const CODE_MODE_RESULT_ECHO_SENTENCE =
51
+ "Nothing in the isolate is echoed automatically: a bare trailing `await tools.<name>(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: \"ls\"})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context.";
52
+
53
+ /**
54
+ * Codex exec / shell-bridge tool names (flat and MCP-prefixed display aliases). An empty result
55
+ * here is almost always a code-mode cell that never called text()/notify().
56
+ */
57
+ export function isCodexExecBridgeTool(toolName?: string, toolNamespace?: string): boolean {
58
+ if (toolNamespace && toolNamespace.includes("opencodex-responses")) return true;
59
+ if (!toolName) return false;
60
+ const lower = toolName.toLowerCase();
61
+ return (
62
+ lower === "exec"
63
+ || lower === "exec_command"
64
+ || lower === "shell_command"
65
+ // Codex CLI/desktop native tool names: the multi-round "이전 출력이 비어 있어 처음부터"
66
+ // restart loop reproduced via codex exec because `shell` was not in this set
67
+ // (devlog 260826 gap-8 QA round 2).
68
+ || lower === "shell"
69
+ || lower === "local_shell"
70
+ || lower === "container.exec"
71
+ || lower.startsWith("mcp_opencodex-responses_")
72
+ || lower.startsWith("mcp__opencodex-responses__")
73
+ );
74
+ }
75
+
76
+ /** True when this result is an exec-bridge call that produced no usable output. */
77
+ export function isEmptyExecToolResult(
78
+ text: string,
79
+ options: { toolName?: string; toolNamespace?: string } = {},
80
+ ): boolean {
81
+ return isCodexExecBridgeTool(options.toolName, options.toolNamespace)
82
+ && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim());
83
+ }
84
+
85
+ /**
86
+ * Returns the guidance text when this is an empty exec-bridge result, else `undefined` so the
87
+ * caller keeps its own fallback. Undefined rather than the original text: an adapter must be able
88
+ * to tell "not my case" from "normalized to the same string".
89
+ */
90
+ export function normalizeEmptyExecToolResultText(
91
+ text: string,
92
+ options: { toolName?: string; toolNamespace?: string } = {},
93
+ ): string | undefined {
94
+ if (!isCodexExecBridgeTool(options.toolName, options.toolNamespace)) return undefined;
95
+ const trimmed = text.trim();
96
+ // Failure first: a failed wrapper must never be described as an empty success.
97
+ if (FAILED_EXEC_OUTPUT_REGEX.test(trimmed)) return FAILED_EXEC_OUTPUT_MESSAGE;
98
+ return EMPTY_EXEC_OUTPUT_REGEX.test(trimmed) ? EMPTY_EXEC_OUTPUT_MESSAGE : undefined;
99
+ }