fix: merge pre-assistant user chunks for pi subagent roster - #11
Open
janmechtel wants to merge 43 commits into
Open
fix: merge pre-assistant user chunks for pi subagent roster#11janmechtel wants to merge 43 commits into
janmechtel wants to merge 43 commits into
Conversation
Adds the ability to pass images alongside text in chat completions. The proxy now extracts base64-encoded images from OpenAI-style image_url content parts, stores them as blobs in Cursor's protobuf format, and threads them through multi-turn conversation state. Also converts all source files from tabs to spaces and adds an .editorconfig to enforce consistent 2-space indentation going forward. Adds .pi-lens/ to .gitignore.
When Cursor issued a tool call, the MCP exec handler paused the HTTP response and stored the bridge in activeBridges. If the bridge then closed with exit code 0 (clean), neither branch in the onClose handler fired — the response and bridge entry were left dangling. Added an else branch to delete the activeBridges entry and call closeResponse() so the connection is always cleaned up.
handleNonStreamingResponse passed a no-op callback for onMcpExec. When Cursor sent exec requests (tool calls), they received no response and Cursor kept waiting indefinitely — the bridge never closed and the promise never resolved, hanging pi -p. Now each exec request is immediately rejected with an error result so Cursor can finish the turn and the bridge closes normally.
The HTTP server and heartbeat setInterval were holding the Node event loop open after a response completed. pi -p had no way to exit without an explicit shutdown signal. server.unref() lets the process exit once there are no active connections. heartbeatTimer.unref() does the same between ticks.
All conversations created by pi carry a session ID, making them session-scoped and permanently exempt from the 30-minute TTL eviction. The eviction path was never reachable in normal usage, so remove CONVERSATION_TTL_MS, evictStaleConversations, sessionScoped, and lastAccessMs entirely.
Two independent causes kept the process alive after a streaming response:
1. The SSE response headers explicitly set Connection: keep-alive, telling
the client (undici) to hold the socket in its connection pool. That
client-side socket is ref'd and blocks process exit. Fixed by setting
Connection: close on all proxy responses.
2. When the h2-bridge process exits naturally, proc.stdin (the parent's
write-end pipe handle is never destroyed because cleanupBridge skips
bridge.end() when bridge.alive is false. Fixed by destroying proc.stdout
and proc.stdin immediately in the proc.on('exit') handler.
Also keeps the earlier server.on('connection', socket => socket.unref())
fix as a defence-in-depth measure for the server-side accepted socket.
Adds a regression test that verifies Connection: close and that the client
socket is destroyed after each response.
EOF
)
- Add tsconfig.json with strict, erasableSyntaxOnly, isolatedModules, skipLibCheck, noUncheckedIndexedAccess, and NodeNext module resolution - Add typescript as a dev dependency for tsc --noEmit checks - Fix all resulting type errors across index.ts, auth.ts, proxy.ts, index.test.ts, and proto/agent_pb.ts (generated file gets @ts-nocheck) - Add explicit return types to exported functions - Replace `any` casts with proper types in normalizeCursorModels, getCursorModels, and proc.stdout unref - Fix real type bug: McpToolCall.result used McpResult/McpError instead of the correct McpToolResult/McpToolError schemas - Add JSON import attributes required by NodeNext ESM - Convert FakeBridge parameter properties to regular fields (erasableSyntaxOnly)
…or cap - Add inferContextWindow(id): derives correct context window from model family (Claude 4.6=1M, Gemini=1M, GPT-5.5+=1M, GPT nano/mini=128k, Grok 4=256k, Kimi K2=262k, etc.) instead of hardcoding 200k for all - Handle session_compact lifecycle event: clear stored checkpoint so the proxy rebuilds from pi's post-compaction history on the next request - Read maxTokens from ConversationTokenDetails and scale total_tokens proportionally when Cursor enforces a tighter cap than inferred, so pi's compaction threshold fires before Cursor errors - Persist effectiveContextWindow per StoredConversation so the scaling factor carries across turns without waiting for the first checkpoint - Bump package to 0.3.0
Expand the fork summary from six to ten improvements, adding sections for per-model cost estimation, model deduplication with reasoning-effort mapping, thinking-tag filtering, and structured debug logging.
Allow for Cursor Composer 2.5
Update cursor-models-raw.json
Three improvements targeting the terminated error and slowness: 1. Bridge timeout hardening (h2-bridge.mjs): raise initial 30s->120s, activity 120s->300s; both configurable via env vars. 2. Bridge termination error propagation (proxy.ts): exit code != 0 now surfaces as a real SSE / 502 error instead of a silent empty success that caused compaction to report 'terminated'. 3. Conversation history archiving (proxy.ts): turns beyond TURN_ARCHIVE_THRESHOLD (default 20, env-configurable) are folded into a ConversationSummaryArchive blob with inline text, cutting getBlobArgs round-trips from O(N) to O(tail) for compaction turns. Bumps package to 0.4.0.
Three fixes targeting the 'Error: Request timed out.' errors and context loss on retry: 1. SSE keepalive (proxy.ts): emit ': ping' every 15 s while the SSE stream is open, preventing pi's HTTP timeout from firing during the silent blob-fetching phase before the first token arrives. 2. Preserve conversation state on error (proxy.ts): remove the two conversationStates.delete(convKey) calls from bridge-timeout and Connect-error paths. The last good checkpoint is unaffected by transient failures and should survive for the retry. 3. Save checkpoint on client disconnect (proxy.ts): remove the !cancelled guard on latestCheckpoint persistence so a checkpoint Cursor already sent before pi timed out is kept for the next request. Same fix applied to the !nonStreamError guard in non-streaming mode. Bumps package to 0.5.0.
- Move Install/Usage/How it works before the changes list - Add consolidated Configuration env vars table - Keep improvements bullet list, place it under Changes vs upstream - Rewrite Session Management to match actual code (remove fingerprinting, branch-change detection, and TTL-eviction references that do not exist) - Reference Configuration section from Bridge timeout and archiving sections instead of repeating env var names inline
…ed cap Anthropic publishes 1M context for claude-4.6-sonnet and claude-4.6-opus, but Cursor's backend enforces a 200k ceiling via ConversationTokenDetails.maxTokens. Registering at the actual enforced limit eliminates the spurious 5x scaling in computeUsage that was inflating reported token counts and causing misleading context-fill percentages in pi's status bar.
Setting previousWorkspaceUris to process.cwd() caused Cursor's backend to compare it against the actual current workspace (none, since Pi has no Cursor IDE workspace) and inject a system_reminder on every first turn: "Workspace folders changed from ~/agent to none." This broke skill auto-loading in Pi because skills are gated on the read tool being available, which requires a non-none workspace context. Revert to empty array (matching the original ephraimduncan/opencode-cursor behavior). The field is only relevant for Cursor's diff/shadow workspace features which Pi does not use, and subsequent turns use the server checkpoint anyway.
h2-bridge now emits structured JSON to stderr for response headers (status, grpc-status) and exit reasons (timeout/connection_error/stream_error), and uses exit code 2 for timeouts to distinguish them from other failures. proxy.ts reads and parses this stderr output via a new StderrData interface and getStderr() method on BridgeHandle. classifyBridgeFailure() maps the combination of exit code, HTTP status, and exit reason to a human-readable message (auth expired, rate limited, network error, timeout, server error, etc.) instead of the previous generic "bridge terminated (exit N)" text. mapConnectErrorCode() similarly translates Connect-protocol error codes (unauthenticated, resource_exhausted, deadline_exceeded, …) to plain English, including detection of context-overflow errors from invalid_argument messages.
Adds a sessionBridges map that tracks every live bridge from spawn to close (activeBridges only covers bridges paused for tool results). Key changes: - startBridge detects a stale bridge for the same key at spawn time, force-kills it, and returns staleBridgeKilled so the caller can warn the user via an SSE chunk. - cleanupBridge sends a cancelAction then schedules a 10 s force-kill so zombie processes do not linger if Cursor never sends END_STREAM. - The inline new-user-message cleanup in handleChatCompletion was calling bridge.end() without sendCancelAction; it now goes through cleanupBridge so Cursor always receives the cancel signal. - handleNonStreamingResponse receives bridgeKey and participates in sessionBridges tracking on the same path as the streaming handler. - Identity-guarded sessionBridges.delete in onClose handlers prevents accidentally removing a bridge that was already replaced.
Since bbe1ea1 surfaced Connect errors as finish_reason:"error", transient Cursor failures (internal, unavailable, deadline_exceeded) became fatal — the user had to manually "continue" each time. The bridge already retried when the process crashed, but not when Cursor returned a protocol-level error (bridge exits cleanly with code 0). Changes: - parseConnectEndStream now returns { message, retryable } instead of Error - End-stream handler kills the bridge on retryable errors (before any content is streamed), triggering the existing onClose retry path - onClose retry condition relaxed to fire on retryableConnectError even without a checkpoint (first request in session) - H2 ping keepalive added to bridge to detect dead TCP connections faster - Session bridge tracking ensures at most one live bridge per session
Tool-call continuations (partial wait path in respondWithPendingToolCalls) previously reported zero usage. Add computeUsageFromStored() that reads persisted lastTotalTokens and scales proportionally when Cursor enforces a tighter context window, so pi sees meaningful token counts even mid-turn.
Changes since 0.5.2: - fix: transparent retry for transient Cursor protocol errors - fix: report usage tokens on tool-call continuation responses
Update Cursor models
When pi sends multiple consecutive user-role messages before the first assistant reply (e.g. the real prompt plus pi-subagents' hidden subagent_roster), parseMessages now merges them into the active userText sent to Cursor instead of keeping only the last chunk. Skip merging once any completed assistant/tool turn exists so interrupt + continue flows still send only the latest user message.
The first-turn-only merge guard left later user prompts dropped when pi-subagents re-injected subagent_roster after completed turns. Merge consecutive user-only chunks when either chunk is the hidden roster, but keep interrupt + continue as separate messages. # Conflicts: # proxy.ts
janmechtel
force-pushed
the
fix/merge-pre-assistant-user-chunks
branch
from
September 4, 2026 21:44
c1af195 to
e54d350
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #10
Problem
With
pi-subagents, Pi emits two consecutive user-role messages before the next assistant reply: the real prompt plus a hiddensubagent_rostercustom message.parseMessages()treated each as a new turn and only forwarded the last chunk to Cursor, so the model often saw only the roster and ignored the actual user question.This happened on the first turn and could happen again on later turns when the roster signature changes and
before_agent_startre-injects it (e.g. privat PDF session 2026-07-02).Fix
Merge consecutive user-only messages into active
userTextwhen either chunk is the hiddensubagent_rosterinjection — on any turn, not only before the first assistant reply.Interrupt + continue flows stay unchanged: two real user messages without roster content are not merged.
Tests
merges consecutive user-only messages into active userText(first turn)merges roster injection after completed turns(later turn — privat PDF case)does not merge interrupt plus continue user messagesdoes not merge interrupt plus continue after completed turnsAll tests pass locally.