Skip to content

feat: add Codex local provider integration for desktop#307

Open
rossmanko wants to merge 7 commits intomainfrom
feat/codex-desktop-integration
Open

feat: add Codex local provider integration for desktop#307
rossmanko wants to merge 7 commits intomainfrom
feat/codex-desktop-integration

Conversation

@rossmanko
Copy link
Contributor

@rossmanko rossmanko commented Mar 21, 2026

Adds Codex (OpenAI Codex CLI) as a local provider option in the desktop app, communicating via Tauri IPC + stdio JSON-RPC. Includes fixes for:

  • Race condition in app-server spawn (compare_exchange instead of load/store)
  • Orphaned process on app exit (RunEvent::Exit cleanup with SIGTERM)
  • Stdin handle not cleared on crash (codex_mark_stopped resets all state)
  • Explicit stderr piping so logs are always captured
  • Message flicker on stream finish (suppress Convex sync echo-back for 2s)
  • Update selectModel tests to match paid-user model override in agent mode

Summary by CodeRabbit

  • New Features

    • Local Codex sidecar for running chats locally, persisting threads; save/load local chats and messages
    • Kimi K2.5 and codex-local models added; notes CRUD and search from desktop
  • Bug Fixes

    • Fixed model preference persistence across chat modes
    • Reduced redundant message syncs and improved editor cleanup on unmount
  • Improvements

    • Model selector: cost indicators, locked-conversation handling, Codex submenu
    • Codex tool outputs (search, diffs, commands) integrated into sidebar views

Adds Codex (OpenAI Codex CLI) as a local provider option in the desktop
app, communicating via Tauri IPC + stdio JSON-RPC. Includes fixes for:
- Race condition in app-server spawn (compare_exchange instead of load/store)
- Orphaned process on app exit (RunEvent::Exit cleanup with SIGTERM)
- Stdin handle not cleared on crash (codex_mark_stopped resets all state)
- Explicit stderr piping so logs are always captured
- Message flicker on stream finish (suppress Convex sync echo-back for 2s)
- Update selectModel tests to match paid-user model override in agent mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hackerai Ready Ready Preview, Comment Mar 26, 2026 0:31am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Mar 21, 2026

📝 Walkthrough

Walkthrough

Adds client-side Codex local-provider support: new CodexLocalTransport and DelegatingTransport, Tauri IPC and sidecar lifecycle, model-selector UI/locking and Codex availability checks, Convex schema/mutations for persisting local chats/messages, Codex tool handling and sidebar parsing, plus related hooks, types, and desktop changes.

Changes

Cohort / File(s) Summary
Chat transport & delegation
lib/local-providers/codex-transport.ts, lib/local-providers/delegating-transport.ts, app/components/chat.tsx
Adds CodexLocalTransport and DelegatingTransport, delegates sending based on selected model, restores/persists Codex thread IDs, integrates Codex-local flow into chat send/reconnect, and adjusts chat sync/queue logic for local streams.
Model selector & toolbar
app/components/ModelSelector.tsx, app/components/ChatInput/ChatInputToolbar.tsx, app/components/ChatInput/ChatInput.tsx
Refactors ModelSelector (Codex submenu, locked UI, Codex icon), adds locked and hasMessages props, always renders ModelSelector in toolbar, and gates selecting Codex-local via availability checks.
Model constants & UI helpers
app/components/ModelSelector/constants.ts, app/components/ModelSelector/icons.tsx, app/components/ModelSelector/CostIndicator.tsx, app/components/ModelSelector/checkCodexStatus.ts
Extracts model option types/lists, adds OpenAI icon and cost indicator component, and implements checkCodexStatus() (Tauri IPC + short-lived cache) used when choosing Codex-local options.
Tool handling & sidebar parsing
app/components/tools/CodexToolHandler.tsx, app/components/MessagePartHandler.tsx, lib/utils/sidebar-utils.ts
Adds CodexToolHandler, routes tool-codex_* parts to it, enhances sidebar extraction for webSearch/fileChange/terminal tool outputs, and adds parseGitDiff to derive original/modified content.
Client hooks & persistence
app/hooks/useCodexLocal.ts, app/hooks/useCodexPersistence.ts, app/hooks/useChatHandlers.ts, app/contexts/GlobalState.tsx
New hook to manage Codex sidecar lifecycle, persistence hook to save Codex messages/thread to Convex, adapt chat handlers to skip server flows for local models, and persist selectedModel per derived mode key.
Convex backend & schema
convex/chats.ts, convex/messages.ts, convex/schema.ts
Adds saveLocalChat and saveLocalMessage client-callable mutations, extends chats schema with optional codex_thread_id, and enforces auth/ownership/validation for persisted local data.
Server guards & model metadata
lib/api/chat-handler.ts, lib/chat/chat-processor.ts, lib/ai/providers.ts, lib/chat/__tests__/chat-processor.test.ts
Server-side guard rejects codex-local* models, adjusts agent-mode model override logic and tests, and adds model entries/metadata (e.g., kimi-k2.5, model-codex-local).
System prompt & types
lib/system-prompt.ts, types/chat.ts
Adds buildLocalSystemPrompt and buildNotesContext, extends SelectedModel with codex-local and submodels plus kimi-k2.5, and adds isCodexLocal/getCodexSubModel helpers.
Desktop (Tauri) integration & editor fix
packages/desktop/src-tauri/src/lib.rs, packages/desktop/src-tauri/Cargo.toml, app/components/DiffView.tsx
Adds Tauri commands for Convex auth and Codex app-server lifecycle, Codex RPC plumbing and notes endpoints, adds reqwest dependency, and fixes Monaco DiffEditor mount/unmount disposal.
Misc / tests / mocks
__mocks__/workos.ts, lib/local-providers/... (tests), app/components/ModelSelector/... (helpers)
Adds WorkOS access-token mock and multiple supporting helpers, tests updated for model selection, and various utility/component additions supporting Codex flows.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Client as Web Client
    participant Delegator as DelegatingTransport
    participant Codex as CodexLocalTransport
    participant AppServer as Codex App-Server
    participant Convex

    User->>Client: Select model & send message
    Client->>Client: isCodexLocal(selectedModel)?
    alt Codex local selected
        Client->>Delegator: sendMessages()
        Delegator->>Codex: sendMessages()
        Codex->>AppServer: start thread / send via Tauri RPC
        AppServer-->>Codex: stream events (text, tool notifications, diffs)
        Codex-->>Client: stream UIMessageChunks
        Client->>Convex: persistCodexMessages() / saveLocalChat()
        Convex-->>Client: ack
    else Remote provider
        Client->>Delegator: sendMessages()
        Delegator->>ClientTransport: delegate to DefaultChatTransport
        ClientTransport->>Server: server-side processing
        Server-->>ClientTransport: response
        ClientTransport-->>Client: render response
    end
    Client->>User: render response + tools/sidebar
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I found a tiny Codex trail,

threads and diffs now leave a trail.
Sidecar hums and tools reply,
models lock while messages fly.
Nibbles of code—hops toward the sky! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: adding Codex local provider integration for desktop. It is specific, non-vague, and directly reflects the substantial work across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/codex-desktop-integration

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (8)
app/components/DiffView.tsx (1)

23-39: Type the editor ref and mount callback instead of using any.

Line 23 and Line 37 currently use any and lose type safety with the DiffEditor component. The DiffEditor's onMount callback from @monaco-editor/react v4.7.0 is typed as (editor: IStandaloneDiffEditor, monaco: Monaco) => void, allowing you to infer precise types directly from the component props.

♻️ Proposed refactor
-  const editorRef = useRef<any>(null);
+  type DiffEditorOnMount = NonNullable<
+    React.ComponentProps<typeof DiffEditor>["onMount"]
+  >;
+  const editorRef = useRef<Parameters<DiffEditorOnMount>[0] | null>(null);
@@
-  const handleEditorMount = (editor: any) => {
+  const handleEditorMount: DiffEditorOnMount = (editor) => {
     editorRef.current = editor;
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/DiffView.tsx` around lines 23 - 39, The editorRef and
handleEditorMount are typed as any losing type safety; change editorRef to use
React.RefObject<IStandaloneDiffEditor | null> and type the mount callback
handleEditorMount as (editor: IStandaloneDiffEditor, monaco: Monaco) => void (or
infer from DiffEditorProps) so the ref holds IStandaloneDiffEditor methods and
dispose is correctly typed; update imports to bring in IStandaloneDiffEditor and
Monaco from 'monaco-editor' and replace any occurrences of editorRef and
handleEditorMount to use the new typed signatures.
app/contexts/GlobalState.tsx (1)

261-266: Unused dependency in useCallback.

chatMode is listed in the dependency array but is not referenced in the callback body. This is harmless but could be cleaned up.

♻️ Suggested fix
   const setSelectedModelState = useCallback(
     (model: SelectedModel) => {
       setSelectedModelRaw(model);
     },
-    [chatMode],
+    [],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/contexts/GlobalState.tsx` around lines 261 - 266, The useCallback for
setSelectedModelState lists chatMode in its dependency array but the callback
only calls setSelectedModelRaw; remove chatMode and include the actual
dependency (setSelectedModelRaw) instead. Update the dependency array for
setSelectedModelState to [setSelectedModelRaw] (or [] only if
setSelectedModelRaw is guaranteed stable) so React hooks linting is satisfied
and the callback dependencies are correct.
lib/chat/__tests__/chat-processor.test.ts (1)

184-209: LGTM!

Test cases correctly updated to reflect the new agent-mode model override behavior for paid users. Good coverage of edge cases including "auto" and undefined overrides.

Consider adding a test case to verify that selectModel throws when passed a "codex-local" or "codex-local:*" model identifier. This would ensure the server-side guard in selectModel is tested.

Would you like me to generate the missing test case for the codex-local rejection guard?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/chat/__tests__/chat-processor.test.ts` around lines 184 - 209, Add a unit
test for selectModel that asserts it throws when given disallowed local
identifiers: call selectModel("agent" or "agent-long", any paid tier like "pro",
with "codex-local" and with a namespaced variant like "codex-local:1") and
expect an error to be thrown; reference the selectModel function in the new test
so it explicitly verifies the server-side guard rejects "codex-local" and
"codex-local:*" model identifiers.
app/hooks/useChatHandlers.ts (1)

247-259: Consider error handling strategy for pre-save failure.

The pre-save mutation catches and logs errors but continues with the stream. If saveLocalChatMutation fails (e.g., Convex is unreachable), the chat will stream but won't be persisted, potentially losing the conversation.

This fire-and-forget pattern is acceptable if the local provider handles its own persistence, but if Convex is the source of truth for local chat history, the user might lose their conversation without knowing. Consider showing a toast warning if the pre-save fails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/hooks/useChatHandlers.ts` around lines 247 - 259, Pre-save failures are
currently swallowed in useChatHandlers.ts (inside the isCodexLocal branch) so
streaming continues while the chat might not be persisted; update the catch
block around saveLocalChatMutation to surface a user-visible warning (e.g., call
your toast/notification helper like showToast or toast.error) that includes a
short, friendly message plus the error detail, and keep the current behavior of
continuing the stream; optionally add a lightweight retry/enqueue-for-later flag
in the same block so the chat can be re-saved later if necessary.
app/components/ModelSelector/checkCodexStatus.ts (2)

5-9: Cache invalidation may cause stale authentication state.

The cache stores { installed: true, authenticated, version } on first successful check and never invalidates. If a user:

  1. Opens the app with Codex installed and authenticated → cached as authenticated
  2. Logs out of Codex CLI externally
  3. Tries to use Codex in the app → cached value still shows authenticated

Consider either:

  • Not caching the authenticated state (only cache installed and version)
  • Adding a manual cache invalidation function for retry scenarios
  • Adding a TTL to the cache
🔧 Suggested modification to avoid caching authentication state
 let _cache: {
   installed: boolean;
-  authenticated: boolean;
   version?: string;
 } | null = null;

 export async function checkCodexStatus(): Promise<{
   installed: boolean;
   authenticated: boolean;
   version?: string;
 } | null> {
-  if (_cache) {
-    return _cache;
+  // Only cache installation status, always re-check authentication
+  if (_cache) {
+    // Re-check auth status even when cached
+    const { invoke } = await import("@tauri-apps/api/core");
+    const authResult = await invoke<{
+      stdout: string;
+      stderr: string;
+      exit_code: number;
+    }>("execute_command", {
+      command: "codex login status",
+      timeoutMs: 5000,
+    });
+    return { ..._cache, authenticated: authResult.exit_code === 0 };
   }
   // ... rest of function
-  _cache = { installed: true, authenticated, version };
+  _cache = { installed: true, version };
-  return _cache;
+  return { ..._cache, authenticated };
 }

Also applies to: 65-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ModelSelector/checkCodexStatus.ts` around lines 5 - 9, The
current module-level _cache object (named _cache) stores the authenticated flag
permanently causing stale state; change checkCodexStatus to stop persisting
authenticated in _cache (only cache installed and version) and compute
authenticated on each call, or alternatively add an explicit invalidate function
(e.g., export function invalidateCodexCache()) or implement a TTL timestamp on
_cache so authenticated is rechecked after expiry; update references to _cache
and the checkCodexStatus function to use the new shape/invalidator and ensure
any callers can trigger invalidateCodexCache() when a retry or logout flow
occurs.

39-42: Non-zero exit code does not necessarily mean "not found".

codex --version returning a non-zero exit code could indicate the CLI crashed, has a bug, or failed for reasons other than not being installed. The current logic logs "Codex CLI not found" which may be misleading.

Consider checking stderr for common "command not found" patterns or adjusting the log message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ModelSelector/checkCodexStatus.ts` around lines 39 - 42, The
code in checkCodexStatus.ts currently treats any non-zero
versionResult.exit_code as "Codex CLI not found"; update the logic in the
checkCodexStatus function to distinguish between a missing executable and other
failures by inspecting versionResult.stderr (and any error code if present) for
common "not found" patterns (e.g., "command not found", "not recognized", "No
such file or directory") or an ENOENT-like error, and only log/return installed:
false when those patterns match; for other non-zero exits, change the log to
indicate the CLI returned an error/crashed and return installed: true with
authenticated: false (or propagate the error) so the message is not misleading.
app/components/tools/CodexToolHandler.tsx (1)

19-22: Consider adding type safety for part prop.

Using any loses type safety. If the Codex tool part structure is known, consider defining a more specific type or union type for better IDE support and compile-time checks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/tools/CodexToolHandler.tsx` around lines 19 - 22, The
CodexToolHandlerProps currently types part as any, losing type safety; define a
specific interface or union (e.g., CodexToolPart or CodexToolPartUnion) that
lists the expected fields used by the component (for example id, type, content,
metadata, status, etc.), replace the any with that new type in
CodexToolHandlerProps, update the CodexToolHandler component signature and any
internal usages to match the new fields, and export/import the type where needed
so IDE and TypeScript checks verify correct usage across the codebase.
lib/local-providers/codex-transport.ts (1)

620-625: rpcNotify propagates invoke errors to caller.

Unlike rpcRequest which catches invoke errors, rpcNotify awaits without try/catch. If the Tauri invoke fails, the error propagates to callers (e.g., rpcNotify("initialized", {}) on line 186). This may be intentional for fire-and-forget notifications, but consider whether silent failure is more appropriate for notifications.

🛡️ Optional: swallow notification errors
 private async rpcNotify(method: string, params: any): Promise<void> {
   const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
   console.log("[CodexTransport] → (notify)", method);
-  const { invoke } = await import("@tauri-apps/api/core");
-  await invoke("codex_rpc_send", { message: msg });
+  try {
+    const { invoke } = await import("@tauri-apps/api/core");
+    await invoke("codex_rpc_send", { message: msg });
+  } catch (err) {
+    console.warn("[CodexTransport] Notification failed:", method, err);
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/local-providers/codex-transport.ts` around lines 620 - 625, Wrap the body
of rpcNotify in a try/catch so Tauri invoke failures do not propagate to
callers: inside the rpcNotify method (which builds msg and calls
invoke("codex_rpc_send", { message: msg })), catch any error from the await
invoke call and handle it silently or log it (e.g., console.error or the
existing logger) instead of rethrowing; this preserves fire-and-forget semantics
while keeping the descriptive console.log("[CodexTransport] → (notify)", method)
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/chat.tsx`:
- Around line 689-699: The short-circuit that compares only message IDs (using
messagesRef.current and uiMessages) drops legitimate updates where a message's
content changed but its id/position did not; update the guard in the chat update
path (where messagesRef.current and uiMessages are compared before returning to
useChat) to either remove the ID-only fast path or broaden the comparison to
detect content changes — e.g., compare a stable message version field
(updatedAt/updatedTimestamp) or perform a shallow/deep equality check of message
objects (not just ids) so updates from api.messages.getMessagesByChatId
propagate to useChat.
- Around line 238-248: The callback must abort the send when the Codex sidecar
fails to start: after calling await ensureSidecarRef.current(), capture its
boolean return value and if it is false, stop further processing (do not call
codexTransport.sendMessages or proceed with the send). Update the async callback
that uses isCodexLocal(selectedModelRef.current) / getCodexSubModel(...) /
codexTransport.setModel(...) / codexTransport.setUserData(...) to check the
result of ensureSidecarRef.current() and return early on failure so the
transport is not invoked.

In `@app/components/DiffView.tsx`:
- Line 115: The DiffEditor is being forced to remount on every streaming update
because the key uses `${originalContent.length}-${modifiedContent.length}`;
remove that unstable key or replace it with a stable identifier from the
streaming payload (for example a revision or message id on resolvedFile or
toolExecutions) so the editor is not recreated on incremental content
changes—locate the DiffEditor render where the key prop is set
(`key={`${originalContent.length}-${modifiedContent.length}`}`) and either drop
the key entirely or set it to a stable property like `resolvedFile.revisionId`
or the message id that only changes for true revisions.

In `@app/components/ModelSelector.tsx`:
- Around line 119-125: The model selector buttons in ModelSelector.tsx are
currently bare <button> elements (e.g., the button that calls onSelect(option)
and the Codex submenu trigger) and thus default to type="submit" inside the chat
form; change each such button to explicitly include type="button" so clicks
don't submit the form—update the button in the group that renders
onSelect(option) and any other buttons in the component (the Codex submenu
trigger and the buttons rendered in the blocks around the referenced areas) to
include type="button".

In `@app/components/tools/CodexToolHandler.tsx`:
- Around line 24-33: The arePropsEqual function on CodexToolHandlerProps
currently omits part.input and part.type, causing stale renders; update
arePropsEqual (used for memoization of CodexToolHandlerProps) to also compare
prev.part.input !== next.part.input and prev.part.type !== next.part.type so
changes to input or type (which drive itemType and the sidebarContent useMemo)
trigger re-renders; keep the same short-circuit style as the existing checks.
- Around line 101-106: The returned runningAction/doneAction for the
"fileChange" case uses naive string concatenation (e.g., `${input?.action ||
"edit"}ing`) which produces incorrect forms; update this case to use a small
action-to-gerund and action-to-past mapping (or a helper like getGerund(action)
and getPastTense(action)) that covers common verbs such as "write" ->
"writing"/"wrote" or "delete" -> "deleting"/"deleted" and falls back to safe
defaults ("editing"/"edited") using input?.action to build runningAction,
doneAction, and keep target as before (input?.path || input?.file || "file");
modify the "fileChange" branch to call these helpers instead of appending "ing"
or "ed".

In `@app/hooks/useCodexLocal.ts`:
- Around line 27-70: The current early-return in ensureSidecar
(startedRef.current && ready) lets the hook skip re-checking the sidecar status
after a crash and allows races on transport.startListening; change ensureSidecar
to always verify the actual backend liveness by calling
get_codex_app_server_info (invoke) instead of trusting ready alone, and only
short-circuit when that probe confirms the server is running. Also serialize
concurrent starts by using the existing setStarting / starting flag or a small
mutex around the start path so multiple callers await the same start sequence
(protect transport.startListening and the invoke("start_codex_app_server")
sequence). Update references in ensureSidecar, startedRef, ready,
transport.startListening and the get_codex_app_server_info invoke call
accordingly.

In `@app/hooks/useCodexPersistence.ts`:
- Around line 71-76: The function in useCodexPersistence.ts that runs
Promise.all (lines ~39-54) currently logs errors in its catch block but still
returns true, incorrectly signaling success; change the catch block in that
function to either return false after logging the error or rethrow the error so
callers receive failure (i.e., replace the current console.error-only catch with
one that returns false or throws), and ensure the function’s callers handle the
boolean/error accordingly.
- Around line 59-63: Replace the silent swallow of errors on saveLocalChat with
error logging: in the block that calls saveLocalChat({ id: chatId, title: "",
codexThreadId: threadId }).catch(() => {}), change the catch to accept the error
and log it (include chatId and threadId for context) using the existing logger
or console.error so failures to persist the thread ID are visible; ensure the
call still does not throw but surfaces the error for debugging.

In `@convex/chats.ts`:
- Around line 205-223: When handling the upsert branch for existing chats in
convex/chats.ts, the patch only updates update_time and codex_thread_id but
never persists a changed selectedModel; update the patch construction in the
existing branch to add selected_model when args.selectedModel is provided (i.e.,
extend the patch Record to include selected_model: args.selectedModel) before
calling ctx.db.patch(existing._id, patch) so existing chats get their
selected_model updated.

In `@convex/messages.ts`:
- Around line 208-221: The deduplication query checks a global message id before
authorization, leaking whether an id exists; move the authorization check
(internal.messages.verifyChatOwnership with { chatId: args.chatId, userId:
user.subject }) to run before the ctx.db query that checks args.id, or
alternatively restrict the dedupe to the target chat by changing the lookup to
include the chat scope (e.g., use/replace the global withIndex("by_message_id")
call with a query that also filters/equates on args.chatId or use an index that
keys by (chatId, id)), so unauthorized callers cannot differentiate existence.

In `@lib/local-providers/codex-transport.ts`:
- Around line 339-341: The code only records the most recent fileChange tool via
lastFileChangeToolCallId, so when the turn/diff/updated event fires only that
single tool call gets the aggregated diff; change the tracking to collect all
fileChange IDs (e.g., replace lastFileChangeToolCallId with an array/Set like
fileChangeToolCallIds when detecting item.type === "fileChange"), add each
item.id to that collection, and then in the turn/diff/updated handler iterate
over fileChangeToolCallIds to apply the updated diff to every corresponding tool
call (and clear the collection after applying); update any references to
lastFileChangeToolCallId accordingly so earlier fileChange tool calls receive
their diffs too.

---

Nitpick comments:
In `@app/components/DiffView.tsx`:
- Around line 23-39: The editorRef and handleEditorMount are typed as any losing
type safety; change editorRef to use React.RefObject<IStandaloneDiffEditor |
null> and type the mount callback handleEditorMount as (editor:
IStandaloneDiffEditor, monaco: Monaco) => void (or infer from DiffEditorProps)
so the ref holds IStandaloneDiffEditor methods and dispose is correctly typed;
update imports to bring in IStandaloneDiffEditor and Monaco from 'monaco-editor'
and replace any occurrences of editorRef and handleEditorMount to use the new
typed signatures.

In `@app/components/ModelSelector/checkCodexStatus.ts`:
- Around line 5-9: The current module-level _cache object (named _cache) stores
the authenticated flag permanently causing stale state; change checkCodexStatus
to stop persisting authenticated in _cache (only cache installed and version)
and compute authenticated on each call, or alternatively add an explicit
invalidate function (e.g., export function invalidateCodexCache()) or implement
a TTL timestamp on _cache so authenticated is rechecked after expiry; update
references to _cache and the checkCodexStatus function to use the new
shape/invalidator and ensure any callers can trigger invalidateCodexCache() when
a retry or logout flow occurs.
- Around line 39-42: The code in checkCodexStatus.ts currently treats any
non-zero versionResult.exit_code as "Codex CLI not found"; update the logic in
the checkCodexStatus function to distinguish between a missing executable and
other failures by inspecting versionResult.stderr (and any error code if
present) for common "not found" patterns (e.g., "command not found", "not
recognized", "No such file or directory") or an ENOENT-like error, and only
log/return installed: false when those patterns match; for other non-zero exits,
change the log to indicate the CLI returned an error/crashed and return
installed: true with authenticated: false (or propagate the error) so the
message is not misleading.

In `@app/components/tools/CodexToolHandler.tsx`:
- Around line 19-22: The CodexToolHandlerProps currently types part as any,
losing type safety; define a specific interface or union (e.g., CodexToolPart or
CodexToolPartUnion) that lists the expected fields used by the component (for
example id, type, content, metadata, status, etc.), replace the any with that
new type in CodexToolHandlerProps, update the CodexToolHandler component
signature and any internal usages to match the new fields, and export/import the
type where needed so IDE and TypeScript checks verify correct usage across the
codebase.

In `@app/contexts/GlobalState.tsx`:
- Around line 261-266: The useCallback for setSelectedModelState lists chatMode
in its dependency array but the callback only calls setSelectedModelRaw; remove
chatMode and include the actual dependency (setSelectedModelRaw) instead. Update
the dependency array for setSelectedModelState to [setSelectedModelRaw] (or []
only if setSelectedModelRaw is guaranteed stable) so React hooks linting is
satisfied and the callback dependencies are correct.

In `@app/hooks/useChatHandlers.ts`:
- Around line 247-259: Pre-save failures are currently swallowed in
useChatHandlers.ts (inside the isCodexLocal branch) so streaming continues while
the chat might not be persisted; update the catch block around
saveLocalChatMutation to surface a user-visible warning (e.g., call your
toast/notification helper like showToast or toast.error) that includes a short,
friendly message plus the error detail, and keep the current behavior of
continuing the stream; optionally add a lightweight retry/enqueue-for-later flag
in the same block so the chat can be re-saved later if necessary.

In `@lib/chat/__tests__/chat-processor.test.ts`:
- Around line 184-209: Add a unit test for selectModel that asserts it throws
when given disallowed local identifiers: call selectModel("agent" or
"agent-long", any paid tier like "pro", with "codex-local" and with a namespaced
variant like "codex-local:1") and expect an error to be thrown; reference the
selectModel function in the new test so it explicitly verifies the server-side
guard rejects "codex-local" and "codex-local:*" model identifiers.

In `@lib/local-providers/codex-transport.ts`:
- Around line 620-625: Wrap the body of rpcNotify in a try/catch so Tauri invoke
failures do not propagate to callers: inside the rpcNotify method (which builds
msg and calls invoke("codex_rpc_send", { message: msg })), catch any error from
the await invoke call and handle it silently or log it (e.g., console.error or
the existing logger) instead of rethrowing; this preserves fire-and-forget
semantics while keeping the descriptive console.log("[CodexTransport] →
(notify)", method) behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6392032e-d6e9-4ef5-b602-bbd4907140b9

📥 Commits

Reviewing files that changed from the base of the PR and between f589949 and d8e8731.

📒 Files selected for processing (28)
  • app/components/ChatInput/ChatInput.tsx
  • app/components/ChatInput/ChatInputToolbar.tsx
  • app/components/DiffView.tsx
  • app/components/MessagePartHandler.tsx
  • app/components/ModelSelector.tsx
  • app/components/ModelSelector/CostIndicator.tsx
  • app/components/ModelSelector/checkCodexStatus.ts
  • app/components/ModelSelector/constants.ts
  • app/components/ModelSelector/icons.tsx
  • app/components/chat.tsx
  • app/components/tools/CodexToolHandler.tsx
  • app/contexts/GlobalState.tsx
  • app/hooks/useChatHandlers.ts
  • app/hooks/useCodexLocal.ts
  • app/hooks/useCodexPersistence.ts
  • convex/chats.ts
  • convex/messages.ts
  • convex/schema.ts
  • lib/ai/providers.ts
  • lib/api/chat-handler.ts
  • lib/chat/__tests__/chat-processor.test.ts
  • lib/chat/chat-processor.ts
  • lib/local-providers/codex-transport.ts
  • lib/local-providers/delegating-transport.ts
  • lib/system-prompt.ts
  • lib/utils/sidebar-utils.ts
  • packages/desktop/src-tauri/src/lib.rs
  • types/chat.ts

Comment on lines +59 to +63
saveLocalChat({
id: chatId,
title: "", // won't overwrite — saveLocalChat patches existing
codexThreadId: threadId,
}).catch(() => {});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent error swallowing hides thread ID persistence failures.

The .catch(() => {}) discards errors without logging. If saveLocalChat fails, the thread ID won't persist and won't survive page reloads, but there's no indication of the failure.

📝 Proposed fix to log the error
         if (threadId) {
           saveLocalChat({
             id: chatId,
             title: "", // won't overwrite — saveLocalChat patches existing
             codexThreadId: threadId,
-          }).catch(() => {});
+          }).catch((err) => {
+            console.error("[CodexLocal] Failed to save thread ID:", err);
+          });
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
saveLocalChat({
id: chatId,
title: "", // won't overwrite — saveLocalChat patches existing
codexThreadId: threadId,
}).catch(() => {});
saveLocalChat({
id: chatId,
title: "", // won't overwrite — saveLocalChat patches existing
codexThreadId: threadId,
}).catch((err) => {
console.error("[CodexLocal] Failed to save thread ID:", err);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/hooks/useCodexPersistence.ts` around lines 59 - 63, Replace the silent
swallow of errors on saveLocalChat with error logging: in the block that calls
saveLocalChat({ id: chatId, title: "", codexThreadId: threadId }).catch(() =>
{}), change the catch to accept the error and log it (include chatId and
threadId for context) using the existing logger or console.error so failures to
persist the thread ID are visible; ensure the call still does not throw but
surfaces the error for debugging.

Comment on lines +208 to +221
// Deduplicate by message id
const existing = await ctx.db
.query("messages")
.withIndex("by_message_id", (q) => q.eq("id", args.id))
.first();
if (existing) {
return null;
}

// Verify chat ownership
const chatExists: boolean = await ctx.runQuery(
internal.messages.verifyChatOwnership,
{ chatId: args.chatId, userId: user.subject },
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Authorize before deduplicating global message ids.

This lookup returns null before internal.messages.verifyChatOwnership runs, so an authenticated caller can distinguish “that message id already exists” from “that chat is unauthorized or missing.” Please flip the order, or scope dedupe to the target chat, so unauthorized requests always fail closed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/messages.ts` around lines 208 - 221, The deduplication query checks a
global message id before authorization, leaking whether an id exists; move the
authorization check (internal.messages.verifyChatOwnership with { chatId:
args.chatId, userId: user.subject }) to run before the ctx.db query that checks
args.id, or alternatively restrict the dedupe to the target chat by changing the
lookup to include the chat scope (e.g., use/replace the global
withIndex("by_message_id") call with a query that also filters/equates on
args.chatId or use an index that keys by (chatId, id)), so unauthorized callers
cannot differentiate existence.

Comment on lines +339 to +341
if (item.type === "fileChange") {
lastFileChangeToolCallId = item.id;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Only the last file change receives the aggregated diff update.

lastFileChangeToolCallId (line 212, 340) tracks only the most recent fileChange tool. When turn/diff/updated fires (line 491), only that last tool gets the updated diff. For turns with multiple file changes, earlier changes won't receive their diff updates.

🔧 Potential fix: track all fileChange IDs
-    let lastFileChangeToolCallId: string | null = null;
+    const fileChangeToolCallIds: string[] = [];

     // In item/started handler:
-    if (item.type === "fileChange") {
-      lastFileChangeToolCallId = item.id;
-    }
+    if (item.type === "fileChange") {
+      fileChangeToolCallIds.push(item.id);
+    }

     // In turn/diff/updated handler:
     case "turn/diff/updated": {
       const diff = params.diff;
-      if (diff && lastFileChangeToolCallId) {
-        safeEnqueue(controller, {
-          type: "tool-output-available",
-          toolCallId: lastFileChangeToolCallId,
-          output: { codexItemType: "fileChange", output: "", diff },
-        });
+      if (diff && fileChangeToolCallIds.length > 0) {
+        // Emit to the last one (or consider emitting to all)
+        for (const toolCallId of fileChangeToolCallIds) {
+          safeEnqueue(controller, {
+            type: "tool-output-available",
+            toolCallId,
+            output: { codexItemType: "fileChange", output: "", diff },
+          });
+        }
       }
       break;
     }

Also applies to: 491-506

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/local-providers/codex-transport.ts` around lines 339 - 341, The code only
records the most recent fileChange tool via lastFileChangeToolCallId, so when
the turn/diff/updated event fires only that single tool call gets the aggregated
diff; change the tracking to collect all fileChange IDs (e.g., replace
lastFileChangeToolCallId with an array/Set like fileChangeToolCallIds when
detecting item.type === "fileChange"), add each item.id to that collection, and
then in the turn/diff/updated handler iterate over fileChangeToolCallIds to
apply the updated diff to every corresponding tool call (and clear the
collection after applying); update any references to lastFileChangeToolCallId
accordingly so earlier fileChange tool calls receive their diffs too.

fkesheh added 2 commits March 24, 2026 19:39
Add REST endpoints on Tauri's cmd server that proxy notes CRUD to Convex
HTTP API, enabling Codex CLI to manage user notes via curl. Includes
system prompt instructions, frontend wiring for cmd server info and
user ID sync, and review fixes for error handling, security, and
UTF-8 correctness.
- Abort send when Codex sidecar fails to start (ensureSidecar check)
- Surface toast warning when local chat pre-save fails
- Wrap rpcNotify in try/catch to prevent unhandled errors
- Add 30s TTL to Codex auth cache to prevent stale state
- Broaden message comparison to detect content updates
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/local-providers/codex-transport.ts`:
- Around line 84-95: The transport currently caches initialized = true forever,
causing initialize() to be skipped after the backend (codex app-server /
sidecar) restarts; update the lifecycle handling so initialized is reset when
the sidecar lifecycle ends or when codex_rpc_send fails: tie initialized to the
sidecar via the eventUnlisten cleanup (set this.initialized = false inside the
unlisten/teardown callback) and also clear initialized in the codex_rpc_send
failure path inside sendMessages()/rpc calls (catch/reject handler should set
this.initialized = false and optionally clear threadIds/rpc state); apply the
same reset behavior to the other initialization flag usage around the
sendMessages/turn/start flow referenced in the 177-203 region so that
initialize() is retried after backend restarts.

In `@packages/desktop/src-tauri/src/lib.rs`:
- Around line 61-68: The set_convex_config desktop command is storing and
exposing a Convex service-role key (see set_convex_config and
convex_service_key_lock), which must not be shipped in the client; remove the
service_key parameter and stop writing to convex_service_key_lock in this
function and any other client-side handlers (also remove usage around lines
noted) and instead send only non-secret config (e.g., url and user_id) from the
client; move service-key usage to a server-owned proxy or issue short-lived
scoped credentials from the server, and update any callers of set_convex_config
to stop passing HACKERAI_SERVICE_KEY over IPC.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bec06278-b258-4c90-9d70-15d22a017a3d

📥 Commits

Reviewing files that changed from the base of the PR and between d8e8731 and 751eb9a.

⛔ Files ignored due to path filters (1)
  • packages/desktop/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • app/components/ModelSelector/checkCodexStatus.ts
  • app/components/chat.tsx
  • app/hooks/useChatHandlers.ts
  • app/hooks/useTauri.ts
  • lib/local-providers/codex-transport.ts
  • lib/system-prompt.ts
  • packages/desktop/src-tauri/Cargo.toml
  • packages/desktop/src-tauri/src/lib.rs
✅ Files skipped from review due to trivial changes (1)
  • packages/desktop/src-tauri/Cargo.toml
🚧 Files skipped from review as they are similar to previous changes (4)
  • lib/system-prompt.ts
  • app/components/ModelSelector/checkCodexStatus.ts
  • app/hooks/useChatHandlers.ts
  • app/components/chat.tsx

Comment on lines +84 to +95
private initialized = false;
private threadIds = new Map<string, string>();
private currentModel: string | undefined;
private promptData: LocalPromptData = {};
private rpcId = 0;
private pendingRequests = new Map<
number,
{ resolve: (result: any) => void; reject: (error: any) => void }
>();
private eventUnlisten: (() => void) | null = null;
private notificationHandler: ((method: string, params: any) => void) | null =
null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset the handshake when codex app-server restarts.

initialized is cached on the transport instance forever. After the Rust side clears state and starts a fresh app-server, the next sendMessages() skips initialize and goes straight to turn/start, so crash recovery can fail until the page reloads. Please tie this flag to the sidecar lifecycle, or clear it whenever the backend reports a restart / codex_rpc_send fails.

Also applies to: 177-203

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/local-providers/codex-transport.ts` around lines 84 - 95, The transport
currently caches initialized = true forever, causing initialize() to be skipped
after the backend (codex app-server / sidecar) restarts; update the lifecycle
handling so initialized is reset when the sidecar lifecycle ends or when
codex_rpc_send fails: tie initialized to the sidecar via the eventUnlisten
cleanup (set this.initialized = false inside the unlisten/teardown callback) and
also clear initialized in the codex_rpc_send failure path inside
sendMessages()/rpc calls (catch/reject handler should set this.initialized =
false and optionally clear threadIds/rpc state); apply the same reset behavior
to the other initialization flag usage around the sendMessages/turn/start flow
referenced in the 177-203 region so that initialize() is retried after backend
restarts.

fkesheh added 3 commits March 25, 2026 05:08
Service keys cannot be embedded in desktop clients running on untrusted
machines. Switch to passing the user's own Convex auth token from the
frontend, using Authorization: Bearer header for HTTP API calls.

- Add createUserNote, updateUserNote, searchUserNotes Convex functions
- Rust backend uses auth token instead of service key
- Frontend syncs WorkOS access token to Tauri every 30s
- Remove all HACKERAI_SERVICE_KEY / ForBackend function references
When notes are disabled in Settings > Personalization > Notes:
- System prompt shows disabled message instead of API docs
- Notes content is not injected into user messages
- REST endpoints return 403 with a clear message
- Setting syncs to Tauri backend every 30s for mid-conversation changes
…token caching

- Randomize heredoc delimiter in saveFileToLocal to prevent command
  injection via crafted content (security fix)
- Remove category from NoteUpdateRequest — updateUserNote doesn't accept it
- Default NOTES_ENABLED to false until frontend syncs user preference
- Cache token to skip redundant setConvexAuth IPC calls
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (7)
convex/notes.ts (2)

784-785: Consider using identity.tokenIdentifier instead of identity.subject.

The coding guidelines recommend preferring identity.tokenIdentifier over identity.subject for auth-linked database lookups. However, I see the existing codebase (lines 567, 634, 641, 690) consistently uses identity.subject for the user_id field.

If this is an intentional pattern for this codebase (e.g., user_id is defined as the subject claim), then this is fine and consistent. Otherwise, consider migrating to tokenIdentifier for stronger identity binding.

As per coding guidelines: "In Convex UserIdentity, prefer identity.tokenIdentifier over identity.subject for auth-linked database lookups or ownership checks."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes.ts` around lines 784 - 785, The insert into notes uses
identity.subject for user_id (see ctx.db.insert("notes", ...) and surrounding
uses of identity.subject); update this to use identity.tokenIdentifier for
auth-linked DB lookups by replacing identity.subject with
identity.tokenIdentifier in the notes insertion (and consider aligning other
occurrences—e.g., the other identity.subject usages at the same module—to
maintain consistency), or explicitly document/confirm that user_id is intended
to store the subject if you choose to leave it unchanged.

988-1000: Search results are unbounded — consider adding .take(n) limit.

The search query collects all matching notes without a limit. While notes are user-scoped and typically won't be massive, the coding guidelines recommend returning bounded collections. Consider adding .take(100) or similar to prevent unexpectedly large result sets.

Suggested change
         notes = await ctx.db
           .query("notes")
           .withSearchIndex("search_notes", (q) => {
             let searchQuery = q
               .search("content", args.search)
               .eq("user_id", identity.subject);
             if (args.category) {
               searchQuery = searchQuery.eq("category", args.category);
             }
             return searchQuery;
           })
-          .collect();
+          .take(100);

As per coding guidelines: "Always return a bounded collection from queries using .take() or pagination instead of .collect() unless explicitly returning all results."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes.ts` around lines 988 - 1000, The search branch currently calls
ctx.db.query("notes").withSearchIndex("search_notes", ...) and ends with
.collect(), returning an unbounded result set; change it to return a bounded
collection by replacing .collect() with a .take(n) (e.g., .take(100)) or
implement pagination, ensuring the withSearchIndex callback (the searchQuery
that uses .search("content", args.search).eq("user_id", identity.subject) and
optional .eq("category", args.category)) remains intact and the final chain uses
.take(...) instead of .collect().
packages/desktop/src-tauri/src/lib.rs (3)

100-102: Include response body in error for easier debugging.

When the Convex API returns a non-success status, the error message only includes the status code, not the response body. Including relevant error details would help diagnose issues.

Suggested change
     if !status.is_success() {
-        return Err(format!("Convex error ({})", status));
+        return Err(format!("Convex error ({}): {}", status, text.chars().take(200).collect::<String>()));
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/desktop/src-tauri/src/lib.rs` around lines 100 - 102, When the
Convex API returns a non-success status the code only returns
Err(format!("Convex error ({})", status)); — update the error path to include
the response body text for diagnostics: read the response body (e.g., via
response.text() / .bytes() depending on the HTTP client) into a string before
returning, and include that string alongside the status in the returned Err.
Locate the place using the status variable (and the HTTP response/response body
variable in the same scope) and append the body content to the error message for
clearer debugging.

88-95: Consider reusing the reqwest client for connection pooling.

A new reqwest::Client is created for each Convex API call. For better performance (connection pooling, fewer allocations), consider storing a static client instance.

Suggested optimization
+static REQWEST_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
+
+fn get_reqwest_client() -> &'static reqwest::Client {
+    REQWEST_CLIENT.get_or_init(|| reqwest::Client::new())
+}
+
 async fn call_convex_function(...) -> Result<String, String> {
     // ...
-    let client = reqwest::Client::new();
+    let client = get_reqwest_client();
     let resp = client.post(&endpoint)
     // ...
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/desktop/src-tauri/src/lib.rs` around lines 88 - 95, The code creates
a new reqwest::Client per call (let client = reqwest::Client::new();), which
prevents connection pooling; replace that local client instantiation with a
single shared static client (e.g., a once_cell::sync::Lazy or lazy_static
CLIENT: reqwest::Client) and use CLIENT.post(&endpoint)... instead of
client.post(...). Ensure the static client is constructed once (optionally with
any desired default builder settings like timeouts or headers) and referenced
where the current client and the send() call are used so all Convex API requests
reuse the same connection pool.

774-793: SIGTERM may not cleanly shut down Codex — consider adding a timeout.

codex_kill() sends SIGTERM (Unix) or taskkill /F (Windows) but doesn't wait for the process to exit. If the process doesn't respond to SIGTERM, it may become orphaned. Consider adding a brief wait followed by SIGKILL if needed.

Note: Since this runs on RunEvent::Exit, the app is about to close anyway, so orphaned processes may be acceptable. However, on Windows, taskkill /F is a force kill, which is inconsistent with the Unix SIGTERM approach.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/desktop/src-tauri/src/lib.rs` around lines 774 - 793, codex_kill
currently sends SIGTERM (unix) or force-kills with taskkill /F (windows) and
immediately returns; change it to send a graceful termination first, wait
briefly with a small timeout and polls for process exit, then escalate to a hard
kill if still running, and only then call codex_mark_stopped. Specifically, in
codex_kill use CODEX_PID and the pid taken from the lock to: (1) on unix call
libc::kill(pid, libc::SIGTERM), then poll for process existence (e.g., loop with
short sleeps up to a total timeout like 1–3s) and if still alive call
libc::kill(pid, libc::SIGKILL); (2) on windows first invoke taskkill without /F,
wait/poll similarly, and if still alive invoke taskkill with /F to force; ensure
the wait is bounded and non-blocking for long shutdowns and keep
codex_mark_stopped() after termination handling.
app/components/chat.tsx (2)

210-215: Unconditional Convex queries may run unnecessarily outside Tauri.

userCustomization and userNotes queries run unconditionally on every render, even when not in the Tauri environment. Since these are only used for Codex local provider, consider conditionally skipping them when not in Tauri.

Suggested optimization
+  const isTauri = isTauriEnvironment();
+
   // Convex queries for local provider prompt data (only fetched when in Tauri desktop)
   const userCustomization = useQuery(
     api.userCustomization.getUserCustomization,
+    isTauri ? undefined : "skip",
   );
-  const userNotes = useQuery(api.notes.getUserNotes, {});
+  const userNotes = useQuery(
+    api.notes.getUserNotes,
+    isTauri ? {} : "skip",
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/chat.tsx` around lines 210 - 215, The two Convex queries
userCustomization (api.userCustomization.getUserCustomization) and userNotes
(api.notes.getUserNotes) are running unconditionally; change them to only run in
the Tauri environment by gating them with a Tauri check (e.g., isTauri() or
window.__TAURI__) — either call useQuery conditionally or pass an enabled/skip
flag to useQuery so queries are disabled when not in Tauri, and ensure the
derived refs userCustomizationRef and userNotesRef (useLatestRef) are created
from the conditional query results (or null/empty fallback) to avoid using
undefined data elsewhere.

503-511: 2-second suppression window may cause missed updates.

The 2-second suppression (codexSyncSuppressedUntilRef.current = Date.now() + 2000) prevents Convex sync after Codex stream finishes to avoid flicker. However, if a legitimate external update occurs within this window (e.g., another device updates a message), it will be ignored.

This is likely acceptable for the Codex local provider use case since it's a single-user desktop context, but worth documenting this trade-off.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/chat.tsx` around lines 503 - 511, The fixed 2s suppression in
the Codex local path (isCodexLocal(selectedModelRef.current)) can drop
legitimate external Convex updates because codexSyncSuppressedUntilRef.current =
Date.now() + 2000 is unconditional; change this by replacing the magic 2000 with
a named constant (e.g., CODEX_SYNC_SUPPRESSION_MS) and either lower it (e.g.,
500ms) or make it configurable, and add a short comment documenting the
trade-off; alternatively, implement a more robust check (e.g., detect message
origin/echo flag on messages in persistCodexMessages or compare message
IDs/timestamps before suppressing) so external updates aren’t silently ignored
while still preventing echo-induced flicker.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/components/chat.tsx`:
- Around line 210-215: The two Convex queries userCustomization
(api.userCustomization.getUserCustomization) and userNotes
(api.notes.getUserNotes) are running unconditionally; change them to only run in
the Tauri environment by gating them with a Tauri check (e.g., isTauri() or
window.__TAURI__) — either call useQuery conditionally or pass an enabled/skip
flag to useQuery so queries are disabled when not in Tauri, and ensure the
derived refs userCustomizationRef and userNotesRef (useLatestRef) are created
from the conditional query results (or null/empty fallback) to avoid using
undefined data elsewhere.
- Around line 503-511: The fixed 2s suppression in the Codex local path
(isCodexLocal(selectedModelRef.current)) can drop legitimate external Convex
updates because codexSyncSuppressedUntilRef.current = Date.now() + 2000 is
unconditional; change this by replacing the magic 2000 with a named constant
(e.g., CODEX_SYNC_SUPPRESSION_MS) and either lower it (e.g., 500ms) or make it
configurable, and add a short comment documenting the trade-off; alternatively,
implement a more robust check (e.g., detect message origin/echo flag on messages
in persistCodexMessages or compare message IDs/timestamps before suppressing) so
external updates aren’t silently ignored while still preventing echo-induced
flicker.

In `@convex/notes.ts`:
- Around line 784-785: The insert into notes uses identity.subject for user_id
(see ctx.db.insert("notes", ...) and surrounding uses of identity.subject);
update this to use identity.tokenIdentifier for auth-linked DB lookups by
replacing identity.subject with identity.tokenIdentifier in the notes insertion
(and consider aligning other occurrences—e.g., the other identity.subject usages
at the same module—to maintain consistency), or explicitly document/confirm that
user_id is intended to store the subject if you choose to leave it unchanged.
- Around line 988-1000: The search branch currently calls
ctx.db.query("notes").withSearchIndex("search_notes", ...) and ends with
.collect(), returning an unbounded result set; change it to return a bounded
collection by replacing .collect() with a .take(n) (e.g., .take(100)) or
implement pagination, ensuring the withSearchIndex callback (the searchQuery
that uses .search("content", args.search).eq("user_id", identity.subject) and
optional .eq("category", args.category)) remains intact and the final chain uses
.take(...) instead of .collect().

In `@packages/desktop/src-tauri/src/lib.rs`:
- Around line 100-102: When the Convex API returns a non-success status the code
only returns Err(format!("Convex error ({})", status)); — update the error path
to include the response body text for diagnostics: read the response body (e.g.,
via response.text() / .bytes() depending on the HTTP client) into a string
before returning, and include that string alongside the status in the returned
Err. Locate the place using the status variable (and the HTTP response/response
body variable in the same scope) and append the body content to the error
message for clearer debugging.
- Around line 88-95: The code creates a new reqwest::Client per call (let client
= reqwest::Client::new();), which prevents connection pooling; replace that
local client instantiation with a single shared static client (e.g., a
once_cell::sync::Lazy or lazy_static CLIENT: reqwest::Client) and use
CLIENT.post(&endpoint)... instead of client.post(...). Ensure the static client
is constructed once (optionally with any desired default builder settings like
timeouts or headers) and referenced where the current client and the send() call
are used so all Convex API requests reuse the same connection pool.
- Around line 774-793: codex_kill currently sends SIGTERM (unix) or force-kills
with taskkill /F (windows) and immediately returns; change it to send a graceful
termination first, wait briefly with a small timeout and polls for process exit,
then escalate to a hard kill if still running, and only then call
codex_mark_stopped. Specifically, in codex_kill use CODEX_PID and the pid taken
from the lock to: (1) on unix call libc::kill(pid, libc::SIGTERM), then poll for
process existence (e.g., loop with short sleeps up to a total timeout like 1–3s)
and if still alive call libc::kill(pid, libc::SIGKILL); (2) on windows first
invoke taskkill without /F, wait/poll similarly, and if still alive invoke
taskkill with /F to force; ensure the wait is bounded and non-blocking for long
shutdowns and keep codex_mark_stopped() after termination handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ebf93050-cfda-4c0d-a74e-2da8eae9a9e3

📥 Commits

Reviewing files that changed from the base of the PR and between 751eb9a and b98ce76.

📒 Files selected for processing (6)
  • __mocks__/workos.ts
  • app/components/chat.tsx
  • app/hooks/useTauri.ts
  • convex/notes.ts
  • lib/system-prompt.ts
  • packages/desktop/src-tauri/src/lib.rs
✅ Files skipped from review due to trivial changes (1)
  • mocks/workos.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/hooks/useTauri.ts

- Remove length-based key from DiffView to prevent streaming remounts
- Add type="button" to raw buttons in ModelSelector
- Add part.input/part.type to CodexToolHandler arePropsEqual
- Reset transport handshake state on Codex process restart
- Emit codex-server-exit event so frontend detects crashes
- Fix persistCodexMessages return value on failure
- Persist selectedModel on chat upsert path
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
app/hooks/useCodexPersistence.ts (1)

57-63: ⚠️ Potential issue | 🟡 Minor

Don't hide thread-ID persistence failures.

If saveLocalChat fails here, reload/resume loses the Codex thread with no diagnostic.

📝 Proposed fix
         if (threadId) {
           saveLocalChat({
             id: chatId,
             title: "", // won't overwrite — saveLocalChat patches existing
             codexThreadId: threadId,
-          }).catch(() => {});
+          }).catch((error) => {
+            console.error("[CodexLocal] Failed to save thread ID", {
+              chatId,
+              threadId,
+              error,
+            });
+          });
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/hooks/useCodexPersistence.ts` around lines 57 - 63, The current code
calls codexTransport.getThreadId(chatId) and then invokes
saveLocalChat({...}).catch(() => {}) which silently swallows persistence
failures; change this to surface errors by removing the empty catch and instead
handle errors by logging them with the app logger (e.g., processLogger.error or
the module's logger) including the chatId and threadId, and/or rethrowing so the
caller can react; update the call site around saveLocalChat in
useCodexPersistence.ts to await/saveLocalChat(...).catch(err => {
processLogger.error("Failed to persist Codex thread for chat", { chatId,
threadId, err }); throw err; }) or similar so failures are not hidden.
lib/local-providers/codex-transport.ts (1)

239-240: ⚠️ Potential issue | 🟡 Minor

Send aggregated diffs to every fileChange, not just the last one.

This still keeps only one lastFileChangeToolCallId, so a multi-file turn replays turn/diff/updated into the final change only. Earlier file changes never receive the aggregated diff.

🔧 Proposed fix
-    let lastFileChangeToolCallId: string | null = null;
+    const fileChangeToolCallIds = new Set<string>();
...
                 if (item.type === "fileChange") {
-                  lastFileChangeToolCallId = item.id;
+                  fileChangeToolCallIds.add(item.id);
                 }
...
-                if (diff && lastFileChangeToolCallId) {
-                  // Re-emit tool output with the full git diff
-                  safeEnqueue(controller, {
-                    type: "tool-output-available",
-                    toolCallId: lastFileChangeToolCallId,
-                    output: {
-                      codexItemType: "fileChange",
-                      output: "",
-                      diff,
-                    },
-                  });
+                if (diff) {
+                  for (const toolCallId of fileChangeToolCallIds) {
+                    safeEnqueue(controller, {
+                      type: "tool-output-available",
+                      toolCallId,
+                      output: {
+                        codexItemType: "fileChange",
+                        output: "",
+                        diff,
+                      },
+                    });
+                  }
                 }

Also applies to: 367-369, 518-531

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/local-providers/codex-transport.ts` around lines 239 - 240, The code only
stores a single lastFileChangeToolCallId so aggregated "turn/diff/updated" is
sent to the final file change only; replace this single-id usage with a
collection (e.g., lastFileChangeToolCallIds: string[] or Set<string>) and update
all places that set/read lastFileChangeToolCallId to push/add the toolCallId to
the collection, then when emitting the aggregated diff (where you currently
reference lastFileChangeToolCallId) iterate the collection and send the
"turn/diff/updated" update for each id; finally, clear the collection at the end
of the multi-file turn. Apply the same replacement in the other occurrences
mentioned (around the blocks currently using lastFileChangeToolCallId).
🧹 Nitpick comments (2)
app/components/ModelSelector.tsx (1)

265-284: Hover-based submenu may close prematurely due to portal gap.

The desktop Popover submenu relies on onMouseEnter/onMouseLeave handlers on both the wrapper div and PopoverContent. Since Radix renders PopoverContent in a portal (outside the wrapper div's DOM subtree), moving the cursor through the 8px sideOffset gap will trigger onMouseLeave on the wrapper before onMouseEnter on the content, potentially closing the submenu.

Consider adding a small delay before closing to bridge the gap:

Example approach with close delay
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();

const handleMouseEnter = () => {
  clearTimeout(closeTimeoutRef.current);
  setSubOpen(true);
};

const handleMouseLeave = () => {
  closeTimeoutRef.current = setTimeout(() => setSubOpen(false), 100);
};

// Use handleMouseEnter/handleMouseLeave on both wrapper and PopoverContent
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ModelSelector.tsx` around lines 265 - 284, The hover-based
submenu can close when the cursor crosses the portal gap; modify the handlers
around Popover/PopoverContent to debounce closing by storing a timeout ref
(e.g., closeTimeoutRef), call clearTimeout(closeTimeoutRef.current) and
setSubOpen(true) on mouse enter, and on mouse leave start a short timeout
(≈100ms) that calls setSubOpen(false); attach these enter/leave handlers to both
the wrapper div and PopoverContent (which is rendered in a portal) so the brief
delay bridges the sideOffset gap and prevents premature closing of subOpen.
app/hooks/useCodexPersistence.ts (1)

39-54: Avoid replaying the full transcript into Convex on every finish.

This re-sends every prior user/assistant message each time a Codex turn completes. As chats grow, write volume becomes quadratic and Promise.all(...) can fan out a large burst of mutations. Persist only new/changed messages, or batch them in one mutation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/hooks/useCodexPersistence.ts` around lines 39 - 54, The current
Promise.all over messages in useCodexPersistence.ts re-sends the entire
transcript on each Codex turn (messages.filter(...).map(...).saveLocalMessage),
causing quadratic writes; change this to only persist new/changed messages or
send them in a single batched mutation: add logic to detect which messages are
new/dirty (e.g., track lastPersistedMessageId or a message.persisted flag) and
filter to that subset, then replace the per-message saveLocalMessage calls with
a single batch-saving API (e.g., implement batchSaveLocalMessages that accepts
an array or a Convex mutation to upsert many messages at once), updating
references to saveLocalMessage and selectedModelRef.current usage accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/tools/CodexToolHandler.tsx`:
- Line 227: The UI marks items as clickable unconditionally (isClickable={true})
even though handleOpenInSidebar returns early when sidebarContent is null,
creating a dead-click; change the isClickable prop to reflect whether
sidebarContent exists (e.g., isClickable={Boolean(sidebarContent)}) and also
guard or conditionally attach the click handler (handleOpenInSidebar) so clicks
are only possible when sidebarContent is non-null, using the same sidebarContent
check to keep behavior and affordance consistent.

In `@lib/local-providers/codex-transport.ts`:
- Around line 155-185: startListening() can be entered concurrently causing
duplicate Tauri subscriptions; add a memoization guard (e.g., a
this.startListeningPromise or temporarily set this.eventUnlisten to a sentinel)
at the top of startListening() and assign it before awaiting
import("@tauri-apps/api/event").listen so subsequent callers await the same
promise instead of creating another listener; ensure the promise is cleared or
the sentinel replaced with the real unlisten function on success and
rejected/cleared on error. This change should be applied around the
startListening() logic that calls listen and sets this.eventUnlisten (also
mirror the same memoization pattern for the analogous code referenced at lines
200-201).
- Around line 203-229: The initialize/start sequence can still trigger a hidden
Codex turn if a fast Stop happens before turnId is set; ensure
cancellation/closed-stream is checked and an interrupt sent even if turnId is
not yet assigned by either (a) setting a provisional turnId before calling
rpcRequest("initialize")/thread start so interrupts can reference it, or (b)
guarding the rpcNotify("turn/start")/rpcNotify("turn/started") calls with a
check for stream closure/cancellation and calling errorStream or sending an
interrupt notification immediately when cancelled; modify the logic around
this.initialized, this.turnId, rpcRequest("initialize"), rpcNotify("turn/start")
and rpcNotify("turn/started") so no backend turn can be started when the local
stream is closed (also apply same fix to the other similar blocks noted).

---

Duplicate comments:
In `@app/hooks/useCodexPersistence.ts`:
- Around line 57-63: The current code calls codexTransport.getThreadId(chatId)
and then invokes saveLocalChat({...}).catch(() => {}) which silently swallows
persistence failures; change this to surface errors by removing the empty catch
and instead handle errors by logging them with the app logger (e.g.,
processLogger.error or the module's logger) including the chatId and threadId,
and/or rethrowing so the caller can react; update the call site around
saveLocalChat in useCodexPersistence.ts to await/saveLocalChat(...).catch(err =>
{ processLogger.error("Failed to persist Codex thread for chat", { chatId,
threadId, err }); throw err; }) or similar so failures are not hidden.

In `@lib/local-providers/codex-transport.ts`:
- Around line 239-240: The code only stores a single lastFileChangeToolCallId so
aggregated "turn/diff/updated" is sent to the final file change only; replace
this single-id usage with a collection (e.g., lastFileChangeToolCallIds:
string[] or Set<string>) and update all places that set/read
lastFileChangeToolCallId to push/add the toolCallId to the collection, then when
emitting the aggregated diff (where you currently reference
lastFileChangeToolCallId) iterate the collection and send the
"turn/diff/updated" update for each id; finally, clear the collection at the end
of the multi-file turn. Apply the same replacement in the other occurrences
mentioned (around the blocks currently using lastFileChangeToolCallId).

---

Nitpick comments:
In `@app/components/ModelSelector.tsx`:
- Around line 265-284: The hover-based submenu can close when the cursor crosses
the portal gap; modify the handlers around Popover/PopoverContent to debounce
closing by storing a timeout ref (e.g., closeTimeoutRef), call
clearTimeout(closeTimeoutRef.current) and setSubOpen(true) on mouse enter, and
on mouse leave start a short timeout (≈100ms) that calls setSubOpen(false);
attach these enter/leave handlers to both the wrapper div and PopoverContent
(which is rendered in a portal) so the brief delay bridges the sideOffset gap
and prevents premature closing of subOpen.

In `@app/hooks/useCodexPersistence.ts`:
- Around line 39-54: The current Promise.all over messages in
useCodexPersistence.ts re-sends the entire transcript on each Codex turn
(messages.filter(...).map(...).saveLocalMessage), causing quadratic writes;
change this to only persist new/changed messages or send them in a single
batched mutation: add logic to detect which messages are new/dirty (e.g., track
lastPersistedMessageId or a message.persisted flag) and filter to that subset,
then replace the per-message saveLocalMessage calls with a single batch-saving
API (e.g., implement batchSaveLocalMessages that accepts an array or a Convex
mutation to upsert many messages at once), updating references to
saveLocalMessage and selectedModelRef.current usage accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 22600202-f90d-4bf4-8bbd-c5d9f6e8af04

📥 Commits

Reviewing files that changed from the base of the PR and between b98ce76 and d9da1df.

📒 Files selected for processing (8)
  • app/components/DiffView.tsx
  • app/components/ModelSelector.tsx
  • app/components/tools/CodexToolHandler.tsx
  • app/hooks/useCodexLocal.ts
  • app/hooks/useCodexPersistence.ts
  • convex/chats.ts
  • lib/local-providers/codex-transport.ts
  • packages/desktop/src-tauri/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • convex/chats.ts
  • app/hooks/useCodexLocal.ts
  • packages/desktop/src-tauri/src/lib.rs

Comment on lines +56 to +63
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++") ||
line.startsWith("new file") ||
line.startsWith("deleted file")
) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

parseGitDiff drops valid hunk content that starts with ---/+++

The header filter runs before hunk-context handling and skips any line starting with --- or +++. In hunks, those can be legitimate removed/added content (e.g. ---flag, +++value), so parsed output can be corrupted.

🐛 Suggested fix
-    if (
-      line.startsWith("diff --git") ||
-      line.startsWith("index ") ||
-      line.startsWith("---") ||
-      line.startsWith("+++") ||
-      line.startsWith("new file") ||
-      line.startsWith("deleted file")
-    ) {
+    if (
+      !inHunk &&
+      (line.startsWith("diff --git") ||
+        line.startsWith("index ") ||
+        line.startsWith("--- ") ||
+        line.startsWith("+++ ") ||
+        line.startsWith("new file ") ||
+        line.startsWith("deleted file "))
+    ) {
       continue;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++") ||
line.startsWith("new file") ||
line.startsWith("deleted file")
) {
if (
!inHunk &&
(line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ") ||
line.startsWith("new file ") ||
line.startsWith("deleted file "))
) {

action={isExecuting ? display.runningAction : display.doneAction}
target={display.target}
isShimmer={isExecuting}
isClickable={true}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clickable affordance should match actual open behavior

Line 227 always sets isClickable={true}, but handleOpenInSidebar exits when sidebarContent is null. That creates dead-click UI in fallback/empty-content cases.

♻️ Suggested fix
-      isClickable={true}
+      isClickable={sidebarContent !== null}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
isClickable={true}
isClickable={sidebarContent !== null}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/tools/CodexToolHandler.tsx` at line 227, The UI marks items as
clickable unconditionally (isClickable={true}) even though handleOpenInSidebar
returns early when sidebarContent is null, creating a dead-click; change the
isClickable prop to reflect whether sidebarContent exists (e.g.,
isClickable={Boolean(sidebarContent)}) and also guard or conditionally attach
the click handler (handleOpenInSidebar) so clicks are only possible when
sidebarContent is non-null, using the same sidebarContent check to keep behavior
and affordance consistent.

Comment on lines +155 to +185
async startListening(): Promise<void> {
if (this.eventUnlisten) return;

const { listen } = await import("@tauri-apps/api/event");
this.eventUnlisten = await listen<string>("codex-rpc-event", (event) => {
try {
const msg = JSON.parse(event.payload);

// Response to a request (has id field)
if ("id" in msg && this.pendingRequests.has(msg.id)) {
const pending = this.pendingRequests.get(msg.id)!;
this.pendingRequests.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error.message || "RPC error"));
} else {
pending.resolve(msg.result);
}
return;
}

// Notification (no id field) — forward to current handler
if (msg.method && this.notificationHandler) {
this.notificationHandler(msg.method, msg.params || {});
}
} catch (err) {
console.warn("[CodexTransport] Failed to parse event:", err);
}
});

console.log("[CodexTransport] Listening for Tauri events");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Memoize startListening() to prevent duplicate Tauri subscriptions.

Line 156 only guards the steady state. If two callers enter startListening() before listen() resolves, both register a handler and every notification gets processed twice.

🔧 Proposed fix
 export class CodexLocalTransport {
+  private listeningPromise: Promise<void> | null = null;
   ...
   reset() {
     ...
+    this.listeningPromise = null;
   }

   async startListening(): Promise<void> {
     if (this.eventUnlisten) return;
+    if (this.listeningPromise) return this.listeningPromise;

-    const { listen } = await import("@tauri-apps/api/event");
-    this.eventUnlisten = await listen<string>("codex-rpc-event", (event) => {
-      try {
-        const msg = JSON.parse(event.payload);
-        // existing handler body
-      } catch (err) {
-        console.warn("[CodexTransport] Failed to parse event:", err);
-      }
-    });
-
-    console.log("[CodexTransport] Listening for Tauri events");
+    this.listeningPromise = (async () => {
+      const { listen } = await import("@tauri-apps/api/event");
+      this.eventUnlisten = await listen<string>("codex-rpc-event", (event) => {
+        try {
+          const msg = JSON.parse(event.payload);
+          // existing handler body
+        } catch (err) {
+          console.warn("[CodexTransport] Failed to parse event:", err);
+        }
+      });
+
+      console.log("[CodexTransport] Listening for Tauri events");
+    })();
+
+    try {
+      await this.listeningPromise;
+    } finally {
+      this.listeningPromise = null;
+    }
   }

Also applies to: 200-201

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/local-providers/codex-transport.ts` around lines 155 - 185,
startListening() can be entered concurrently causing duplicate Tauri
subscriptions; add a memoization guard (e.g., a this.startListeningPromise or
temporarily set this.eventUnlisten to a sentinel) at the top of startListening()
and assign it before awaiting import("@tauri-apps/api/event").listen so
subsequent callers await the same promise instead of creating another listener;
ensure the promise is cleared or the sentinel replaced with the real unlisten
function on success and rejected/cleared on error. This change should be applied
around the startListening() logic that calls listen and sets this.eventUnlisten
(also mirror the same memoization pattern for the analogous code referenced at
lines 200-201).

Comment on lines +203 to +229
// Initialize handshake (first time only)
if (!this.initialized) {
try {
console.log("[CodexTransport] Initializing...");
await this.rpcRequest("initialize", {
clientInfo: {
name: "hackerai",
title: "HackerAI Desktop",
version: "0.1.0",
},
});
this.rpcNotify("initialized", {});
this.initialized = true;
console.log("[CodexTransport] Initialized");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// "Already initialized" means the process survived HMR — treat as success
if (msg.includes("Already initialized")) {
console.log(
"[CodexTransport] Already initialized (process survived reload)",
);
this.initialized = true;
} else {
return this.errorStream(`Initialize failed: ${msg}`);
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

A fast Stop can still launch a hidden Codex turn.

Lines 563-585 only interrupt once turnId exists. If the user cancels during initialize/thread start or before turn/started, no interrupt is sent and the code still reaches turn/start on Lines 592-596. Because the stream is already closed locally at that point, the backend can keep running commands or file changes unseen. With sandbox: "danger-full-access" on Lines 278-279, this is release-blocking.

🛑 Proposed fix
+          let abortRequested = abortSignal?.aborted ?? false;
+
           // Set up notification handler for this turn
           this.notificationHandler = (method, params) => {
             switch (method) {
               case "turn/started": {
                 turnId = params.turn?.id;
+                if (abortRequested && turnId) {
+                  this.rpcNotify("turn/interrupt", { threadId, turnId });
+                  this.notificationHandler = null;
+                  safeEnqueue(controller, { type: "finish-step" });
+                  safeEnqueue(controller, {
+                    type: "finish",
+                    finishReason: "stop",
+                  });
+                  safeClose(controller);
+                  break;
+                }
                 ...
               }
             }
           };

           // Handle abort
           if (abortSignal) {
+            if (abortSignal.aborted) {
+              safeEnqueue(controller, { type: "finish-step" });
+              safeEnqueue(controller, {
+                type: "finish",
+                finishReason: "stop",
+              });
+              safeClose(controller);
+              return;
+            }
+
             abortSignal.addEventListener(
               "abort",
               () => {
+                abortRequested = true;
                 if (turnId && threadId) {
                   this.rpcNotify("turn/interrupt", { threadId, turnId });
+                  this.notificationHandler = null;
+                  safeEnqueue(controller, { type: "finish-step" });
+                  safeEnqueue(controller, {
+                    type: "finish",
+                    finishReason: "stop",
+                  });
+                  safeClose(controller);
+                  return;
                 }
-                // existing local close logic
               },
               { once: true },
             );
           }

+          if (abortRequested) return;
+
           await this.rpcRequest("turn/start", {

Also applies to: 257-292, 562-596

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/local-providers/codex-transport.ts` around lines 203 - 229, The
initialize/start sequence can still trigger a hidden Codex turn if a fast Stop
happens before turnId is set; ensure cancellation/closed-stream is checked and
an interrupt sent even if turnId is not yet assigned by either (a) setting a
provisional turnId before calling rpcRequest("initialize")/thread start so
interrupts can reference it, or (b) guarding the
rpcNotify("turn/start")/rpcNotify("turn/started") calls with a check for stream
closure/cancellation and calling errorStream or sending an interrupt
notification immediately when cancelled; modify the logic around
this.initialized, this.turnId, rpcRequest("initialize"), rpcNotify("turn/start")
and rpcNotify("turn/started") so no backend turn can be started when the local
stream is closed (also apply same fix to the other similar blocks noted).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants