Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
92803bc
chore(proxy): restore controlled baseline and preserve overlay evidence
hffmnnj Aug 10, 2026
a50cbab
chore: ignore local tooling state directory
hffmnnj Aug 10, 2026
b6864af
feat(proxy): add redacted lifecycle diagnostics
hffmnnj Aug 10, 2026
fd5d48a
docs(proxy): map lifecycle and termination evidence
hffmnnj Aug 10, 2026
e0531c5
chore: exempt overlay-baseline.patch from whitespace checks
hffmnnj Aug 10, 2026
ce60edc
test(connect): add bounded Cursor transport fixture
hffmnnj Aug 10, 2026
dbabfaf
test(proxy): reproduce streaming termination failures
hffmnnj Aug 10, 2026
258e961
test(proxy): reproduce resume and pre-header stalls
hffmnnj Aug 10, 2026
fb62d03
fix(proxy): enforce Connect stream termination
hffmnnj Aug 10, 2026
b9e76ce
fix(proxy): classify all Cursor server messages
hffmnnj Aug 10, 2026
d7d3a8a
fix(proxy): propagate terminal transport failures
hffmnnj Aug 10, 2026
c37ecf4
fix(proxy): contain unary protocol failures
hffmnnj Aug 10, 2026
0b6dea4
fix(auth): bound token refresh requests
hffmnnj Aug 10, 2026
28e0ea5
fix(transport): detect failed HTTP2 sessions
hffmnnj Aug 10, 2026
f100c3e
fix(proxy): isolate paused tool resumes
hffmnnj Aug 10, 2026
a17f018
fix(proxy): bound semantic response stalls
hffmnnj Aug 10, 2026
28a2e9a
fix(proxy): preserve long reasoning waits
hffmnnj Aug 10, 2026
1ffca14
test(proxy): cover semantic progress rearming
hffmnnj Aug 10, 2026
39c6a15
docs(baseline): record inherited stability floor
hffmnnj Aug 12, 2026
25bd23f
docs(provenance): record Grok 4.6 research and prior-art survey verdicts
hffmnnj Aug 12, 2026
d7055cd
fix(proxy): tear down bridge on client stream cancellation
hffmnnj Aug 12, 2026
f3dc19b
docs(provenance): cite uejn0r60 registry surface map
hffmnnj Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# docs/overlay-baseline.patch is a verbatim preserved evidence artifact: a unified diff
# represents a blank source line as a context line containing a single space, which
# `git diff --check` flags as trailing whitespace. This is intentional — never strip or
# reflow the file, or git apply fidelity is lost.
docs/overlay-baseline.patch -whitespace
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
dist/
*.tsbuildinfo
.DS_Store
.goopspec/
506 changes: 506 additions & 0 deletions docs/cursor-hang-root-cause.md

Large diffs are not rendered by default.

165 changes: 165 additions & 0 deletions docs/grok-4-6-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Grok 4.6 Research Provenance and Prior-Art Survey

Status: **Record of verified Grok 4.6 facts, explicit uncertainty, and prior-art verdicts.**
Evidence was gathered on 2026-08-12 by the workflow's researcher and scout (Field Notes below);
repository gates were re-verified by Wave 1 Task 1.1 at the inherited tip. This document satisfies
must-have **MH-1** and **MH-2**. The origin PR #34 cancellation verdict and client-abort lifecycle
evidence are recorded in [§8](#8-origin-pr-34-verdict-adapted-client-abort-lifecycle).

## 1. Verified Grok 4.6 facts

Each row records the value, the authoritative source, and the Field Note that verified it.

| Fact | Value | Source |
|---|---|---|
| xAI API model id | `grok-4.6` | https://docs.x.ai/developers/release-notes (August 2026 entry); https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` |
| Cursor-facing surface | "Grok 4.6" with **256K default context**, capabilities **Agent + Thinking** | https://docs.cursor.com/models; `fn_20260812_vaamzb8z` |
| xAI direct-API context window | 500,000 tokens | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` |
| Input modalities | text and image | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` |
| Output modality | text only; **no stated output limit** | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` |
| Short-context pricing per 1M tokens | $2.00 input / $6.00 output (below 200k prompt tokens; $0.50 cached-read) | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z`; `fn_20260812_o13isq9e` |
| Reasoning effort | low, medium, high (default), through **xhigh** | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` |

Additional verified context from the same sources: knowledge cutoff February 1, 2026; Responses API
and Chat Completions API; tools function calling, web search, X search, and code execution;
distribution includes the xAI API, Grok Build, Cursor (all plans), OpenRouter, Vercel, and
Cloudflare (`fn_20260812_vaamzb8z`).

**Metadata surfaces.** Cursor-facing values (256K default context, Agent + Thinking) come from
`docs.cursor.com/models` and describe what Cursor's Connect API serves. xAI direct-API values (500K
context, pricing, reasoning effort) come from `docs.x.ai`. The plugin proxies Cursor's Connect API,
so catalog limits should mirror the Cursor-facing 256K value; the 500K API discrepancy is documented
rather than hidden (`fn_20260812_vaamzb8z`, `fn_20260812_o13isq9e`). The existing `/grok/i` cost
pattern already routes `grok-4.6` to the `grok-4.20` key (`fn_20260812_uejn0r60`), whose $2/$6 rates
match Grok 4.5/4.6 short-context pricing; `grok-4.20` itself is a different xAI model at $1.25/$2.50
(`fn_20260812_o13isq9e`, https://docs.x.ai/developers/models/grok-4.20-experimental-beta-0304).

## 2. Explicit uncertainty

These items are **not** asserted as verified. Each is labelled with its confidence grade from the
source Field Note.

- **Cursor-facing model id string: UNVERIFIED (inference).** Presumed `grok-4.6`, matching the xAI
id and Cursor's docs path `/docs/models/grok-4-6`, but not yet confirmed against a live
`GetUsableModels` response (`fn_20260812_vaamzb8z`, "INFERENCE / UNCERTAIN"). Spec assumption A1
and the risk table treat a differing id as an open risk; live confirmation is scheduled for Wave 5.
The fallback catalog entry is a best-effort resilience path, not the authoritative discovery path.
- **Billing pool: NO CLAIM MADE.** Whether Grok 4.6 on Cursor draws from the first-party pool or the
API/third-party pool is UNCONFIRMED. Launch-day Reddit reports are anecdotal and internally
inconsistent with Cursor's own grok page
(https://www.reddit.com/r/cursor/comments/1vmmfgc/grok_46_thoughts_usage/, INFERENCE grade,
`fn_20260812_vaamzb8z`). No plugin metadata depends on a pool, so this document makes no
billing-pool claim.
- **Rollout cadence: uncertain.** Cursor's changelog had no dedicated Grok 4.6 entry as of the
research date even though the models page lists the model; rollout may be gradual
(`fn_20260812_vaamzb8z`).

## 3. Fork survey: no prior art to cherry-pick

Origin `ephraimduncan/opencode-cursor` plus **24 surveyed forks** were searched via GitHub API fork,
commit, and code-search queries run 2026-08-12. Result: **zero `grok-4.6` code hits anywhere**
(`fn_20260812_gz125m33`). Independent corroboration: `gh search commits "grok 4.6"` and
`gh search issues "grok 4.6"` returned empty; the 50-item origin PR list contains no Grok PRs; six
local remote-only branches carry zero grok references in `src/`; origin/main (`a37a6ba`, v0.1.1)
fallback models contain only `grok-code-fast-1` (`fn_20260812_zyc1iqd0`).

Verdict: **no cherry-pick exists.** Grok 4.6 catalog work is greenfield for this workflow. Upstream
issue #33, the acceptance-defining hang report, remains open (`fn_20260812_gz125m33`,
https://github.com/ephraimduncan/opencode-cursor/issues/33).

## 4. Adapted prior art: tanushshukla a067e099

Commit [a067e099](https://github.com/tanushshukla/opencode-cursor/commit/a067e099)
("fix: end OpenAI stream on Cursor turnEnded", +203/-30 in `proxy.ts`) is recorded as **adapted
prior art, with attribution**, for two behaviors (`fn_20260812_gz125m33`, `fn_20260812_b0tcb20c`):

- `turnEnded` as a clean terminal: finalize the SSE stream with `finish_reason: stop` plus `[DONE]`,
tear down the parked bridge, and guard the later close handler against double-reporting.
- `interactionQuery` answers with typed rejections (web search / ask / plan / exa / VM) so the model
can continue instead of waiting forever.

**Behavioral adoption is evidence-gated, not committed here.** This branch currently treats
`turnEnded` as non-terminal (`src/proxy.ts:1294-1296`) and errors on `interactionQuery`
(`src/proxy.ts:1233-1237`) (`fn_20260812_b0tcb20c`). Whether to adopt the fork's behavior is decided
by Wave 3 deterministic fixtures plus isolated live observation, per spec assumption A7; any
adaptation must preserve this fork's stricter abnormal-path contract, which the upstream commit does
not have (`fn_20260812_b0tcb20c`; BLUEPRINT, "Prior art and provenance"). This section records
provenance only.

## 5. Rejected upstream work: origin PR #36

Origin PR [#36](https://github.com/ephraimduncan/opencode-cursor/pull/36)
("fix: run on Node runtime (Desktop sidecar)", intellectronica, +19577/-97) replaces `Bun.*` with
`node:http` + `child_process` and changes module format. **Rejected as orthogonal**: the Bun runtime
is this workflow's constraint, the diff is large, and the conflict surface is high; it addresses no
Grok 4.6 or stall requirement here (`fn_20260812_gz125m33`, `fn_20260812_zyc1iqd0`).

## 6. Disposition of the 18 inherited stability commits

The branch carries 18 stability commits inherited from the earlier hang investigation (base
`origin/main` `a37a6ba`; tip `1ffca14`; +2505/-176; commit-level audit in `fn_20260812_fppld7hu`).
They are **already present in branch history**, so there is nothing to cherry-pick or reimplement.

Wave 1 Task 1.1 re-verified them at the inherited tip `1ffca14`
(`1ffca147b461ea044e041626eb1ccc7465169526`) on `chore/stability-baseline`: `npx tsc -p tsconfig.json
--noEmit` PASS, `bun test/smoke.ts` PASS with 48 required assertions and 2 documented quarantines,
`bun run build` PASS. That record is the **accepted floor** for this workflow, written by commit
`39c6a15` (`docs(baseline): record inherited stability floor`) into
[`docs/cursor-hang-root-cause.md`](./cursor-hang-root-cause.md) ("Baseline status at inherited tip")
and [`docs/overlay-baseline.note.md`](./overlay-baseline.note.md) (`fn_20260812_fppld7hu`;
`docs/cursor-hang-root-cause.md:42-65`).

Coverage those commits bring: bounded token refresh, strict Connect stream termination, an
idempotent terminal owner, a semantic-progress watchdog, HTTP/2 PING and session lifecycle handling,
tool-resume isolation, unary/non-streaming bounds, redacted lifecycle diagnostics, a fake
Connect/H2 fixture, and roughly 30 smoke assertions (`fn_20260812_fppld7hu`).

## 7. Overlay disposition

`docs/overlay-baseline.patch` is preserved as verbatim evidence and **MUST NOT be reapplied**; the
adjacent note and the RCA record the three-way split
([`docs/overlay-baseline.note.md`](./overlay-baseline.note.md);
`docs/cursor-hang-root-cause.md`, "Overlay provenance"):

- **Kept / already landed:** stderr draining, sanitized error reporting, defensive close and destroy
guards.
- **Rejected:** the fixed timeout increases (30 to 60s connect; 120 to 180s idle).
- **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus H2 liveness.

## 8. Origin PR #34 verdict: ADAPT (client-abort lifecycle)

Origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34)
("fix: handle cancelled SSE streams", noamkush, +4/-1) guards `sendSSE` with a `closed` flag to stop
`ERR_INVALID_STATE: Controller is already closed` when the client cancels
(`fn_20260812_gz125m33`). It is stall-adjacent to this fork's cancellation and termination path.

**Verdict: ADAPT.** PR #34 correctly identifies the caller-cancellation boundary, but its `closed`
guard alone is insufficient for this fork: it only suppresses a later `controller.enqueue`, leaving
the bridge child, Connect/H2 stream, heartbeat, semantic-progress watchdog, paused-bridge indexes,
and this fork's idempotent terminal owner outside its scope.

This fork already owns those resources through `createBridgeStreamResponse`'s idempotent terminal
owner: normal/error termination clears the semantic watchdog and heartbeat, removes matching
`activeBridges`/tool-call index entries, writes the one terminal SSE sequence, closes the controller,
and terminates the child. The bridge close callback persists current blobs and checkpoint state into
the existing `conversationStates` record, then cannot re-enter terminal cleanup. A paused bridge is
removed when its retained transport exits; durable conversation state is intentionally retained for
the TTL/disk-cache recovery contract.

Wave 1 Task 1.3 adapts PR #34's missing cancellation guard to this ownership model: controller
closed-state now belongs to the shared response owner and `ReadableStream.cancel()` marks it before
clearing the watchdog/heartbeat, removing matching paused indexes, and terminating the bridge. It
does **not** enqueue or close the already consumer-cancelled controller, so late bridge callbacks
cannot write or produce a second close. `test/smoke.ts` uses the deterministic `client-abort` fake
Connect scenario to read one SSE frame, cancel the reader, and prove fixture-observed H2/session
cleanup, with zero retained proxy-side active bridges verified by source inspection. Typecheck and
the full deterministic smoke suite pass (2026-08-12).

Evidence: origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) (+4/-1,
`closed` set by `cancel()`); `fn_20260812_gz125m33`; `src/proxy.ts:createBridgeStreamResponse`;
`test/fixtures/fake-cursor-server.ts:client-abort`; `test/smoke.ts:testClientAbortTeardown`.

## Verification performed for this document

- `bun run build` — PASS (documentation-only change; no source, tests, or dependencies touched).
- `git diff --check` — PASS for the new document.
18 changes: 18 additions & 0 deletions docs/overlay-baseline.note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Overlay Baseline Disposition

Status: **preserved evidence; accepted as historical input only.** The verbatim
unified diff in [`overlay-baseline.patch`](./overlay-baseline.patch) records the
speculative overlay that preceded the controlled stability work. It MUST NOT be
reapplied. Its meaningful whitespace is part of the evidence artifact and must
remain unchanged.

At inherited tip `1ffca14` (`1ffca147b461ea044e041626eb1ccc7465169526`), the
overlay is dispositioned as follows:

- **Kept/already landed:** stderr draining, sanitized errors, and defensive
close/destroy guards.
- **Rejected:** fixed 30→60s connect and 120→180s idle timeout bumps.
- **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus
H2 liveness.

The repository gates were rerun against this tip and are recorded in the RCA.
195 changes: 195 additions & 0 deletions docs/overlay-baseline.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
diff --git a/.gitignore b/.gitignore
index 62ccde4..4d58f8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules/
dist/
*.tsbuildinfo
.DS_Store
+.goopspec/
diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs
index e86132e..b16ca15 100644
--- a/src/h2-bridge.mjs
+++ b/src/h2-bridge.mjs
@@ -89,21 +89,28 @@ const client = http2.connect(url || "https://api2.cursor.sh");

// Guard against initial connection failure. Reset on any h2 activity
// so long-running agent conversations (with tool call round-trips) survive.
-let timeout = setTimeout(killBridge, 30_000);
+// Initial connection timeout is generous because Cursor's server can be slow
+// to respond during peak load.
+let timeout = setTimeout(killBridge, 60_000);

function resetTimeout() {
clearTimeout(timeout);
- timeout = setTimeout(killBridge, 120_000);
+ // 180s idle timeout: long enough for Grok 4.5 thinking/tool-call pauses,
+ // short enough to not hang OpenCode indefinitely.
+ timeout = setTimeout(killBridge, 180_000);
}

function killBridge() {
clearTimeout(timeout);
- client.destroy();
+ try { client.destroy(); } catch {}
process.exit(1);
}

-client.on("error", () => {
+client.on("error", (err) => {
clearTimeout(timeout);
+ // Write the error to stderr so the parent process can see it.
+ // Without this, errors are silently swallowed.
+ try { process.stderr.write(`[h2-bridge] client error: ${err.message}\n`); } catch {}
process.exit(1);
});

@@ -136,9 +143,10 @@ h2Stream.on("end", () => {
setTimeout(() => process.exit(0), 100);
});

-h2Stream.on("error", () => {
+h2Stream.on("error", (err) => {
clearTimeout(timeout);
- client.close();
+ try { process.stderr.write(`[h2-bridge] stream error: ${err.message}\n`); } catch {}
+ try { client.close(); } catch {}
process.exit(1);
});

diff --git a/src/proxy.ts b/src/proxy.ts
index 93c44ec..b74b2be 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -308,7 +308,7 @@ function spawnBridge(options: SpawnBridgeOptions): {
const proc = Bun.spawn(["node", BRIDGE_PATH], {
stdin: "pipe",
stdout: "pipe",
- stderr: "ignore",
+ stderr: "pipe",
});

const config = JSON.stringify({
@@ -319,6 +319,20 @@ function spawnBridge(options: SpawnBridgeOptions): {
});
proc.stdin.write(lpEncode(new TextEncoder().encode(config)));

+ // Forward stderr to console for debugging. Without this, bridge errors
+ // are silently swallowed and the proxy appears to hang for no reason.
+ (async () => {
+ try {
+ const reader = proc.stderr.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const text = new TextDecoder().decode(value);
+ if (text.trim()) console.error(`[h2-bridge] ${text.trimEnd()}`);
+ }
+ } catch {}
+ })();
+
const cbs = {
data: null as ((chunk: Buffer) => void) | null,
close: null as ((code: number) => void) | null,
@@ -469,7 +483,7 @@ export async function startProxy(

proxyServer = Bun.serve({
port: 0,
- idleTimeout: 255, // max — Cursor responses can take 30s+
+ idleTimeout: 255, // max - Cursor responses can take 30s+, especially Grok 4.5
async fetch(req) {
const url = new URL(req.url);

@@ -1511,10 +1525,50 @@ function createBridgeStreamResponse(
},
);

- bridge.onData(processChunk);
+ // --- Watchdog timer ---
+ // If no data arrives within TOOL_CALL_TIMEOUT_MS, force-close the stream
+ // so OpenCode doesn't hang waiting for a response that will never come.
+ // This catches: H2 socket close without Connect end-stream frame,
+ // silent server disconnects, and model stalls (especially Grok 4.5).
+ const TOOL_CALL_TIMEOUT_MS = 90_000; // 90s
+ let lastDataTime = Date.now();
+ const watchdog = setInterval(() => {
+ if (closed) {
+ clearInterval(watchdog);
+ return;
+ }
+ if (Date.now() - lastDataTime > TOOL_CALL_TIMEOUT_MS) {
+ clearInterval(watchdog);
+ if (!mcpExecReceived) {
+ const flushed = tagFilter.flush();
+ if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
+ if (flushed.content) sendSSE(makeChunk({ content: flushed.content }));
+ }
+ sendSSE(makeChunk({
+ content: "\n[Error: request timed out - no data from Cursor server]",
+ }));
+ sendSSE(makeChunk({}, "stop"));
+ sendSSE(makeUsageChunk());
+ sendDone();
+ closeController();
+ activeBridges.delete(bridgeKey);
+ clearInterval(heartbeatTimer);
+ bridge.end();
+ }
+ }, 5_000);
+
+ // Wrap processChunk to track data arrival for watchdog
+ const originalProcessChunk = processChunk;
+ const trackedProcessChunk = (incoming: Buffer) => {
+ lastDataTime = Date.now();
+ originalProcessChunk(incoming);
+ };
+
+ bridge.onData(trackedProcessChunk);

bridge.onClose((code) => {
clearInterval(heartbeatTimer);
+ clearInterval(watchdog);
const stored = conversationStates.get(convKey);
if (stored) {
for (const [k, v] of blobStore) stored.blobStore.set(k, v);
@@ -1529,16 +1583,30 @@ function createBridgeStreamResponse(
sendSSE(makeUsageChunk());
sendDone();
closeController();
- } else if (code !== 0) {
- // Bridge died while tool calls are pending (timeout, crash, etc.).
- // Close the SSE stream so the client doesn't hang forever.
- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
- sendSSE(makeChunk({}, "stop"));
- sendSSE(makeUsageChunk());
- sendDone();
- closeController();
- // Remove stale entry so the next request doesn't try to resume it.
- activeBridges.delete(bridgeKey);
+ } else {
+ // Bridge closed while tool calls are pending.
+ // Whether the exit code is 0 (server clean-close) or non-zero
+ // (timeout/crash), if mcpExecReceived is true, the bridge is
+ // kept alive for continuation. When it dies unexpectedly, the
+ // next request will detect a dead bridge and fall through to a
+ // fresh one. But we must NOT close the SSE stream here when
+ // code === 0 and mcpExecReceived is true, because that means
+ // the tool calls were emitted and OpenCode is executing them.
+ // The stream was already closed by the onMcpExec handler.
+ if (code !== 0) {
+ // Bridge died with error while tool calls pending.
+ // Close the SSE stream so the client doesn't hang forever.
+ sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
+ sendSSE(makeChunk({}, "stop"));
+ sendSSE(makeUsageChunk());
+ sendDone();
+ closeController();
+ // Remove stale entry so the next request doesn't try to resume it.
+ activeBridges.delete(bridgeKey);
+ }
+ // When code === 0 and mcpExecReceived: the SSE stream was already
+ // closed by onMcpExec. Nothing more to do. The bridge is kept
+ // alive in activeBridges for the tool-result resume path.
}
});
},
Loading