Skip to content

fix(mobile): keep a not-sent message in place on the phone, using the desktop's logic - #27936

Merged
brennanb2025 merged 11 commits into
mainfrom
brennanb2025/phone-not-sent-v2
Oct 12, 2026
Merged

brennanb2025 merged 11 commits into
mainfrom
brennanb2025/phone-not-sent-v2

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 38 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1008 $\color{#cf222e}{\Huge{\mathbf{−}}}$​389 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​619
Prod 29 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​426 $\color{#cf222e}{\Huge{\mathbf{−}}}$​335 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​91

ELI5

You send a message from the Orca phone app. The computer running the agent (the host) records it, but then can't hand it to the agent: the agent failed to start, Orca restarted first, or the agent refused it. The phone hid that message. Depending on timing it vanished, bounced back into the message box with a red banner, or sat there as a copy that looked sent and never explained itself. The desktop has kept such a message in place, labelled not sent, since #24710.

Now the phone does the same, using the desktop's own logic: the message stays where you sent it, with a small red line under it saying it was not sent and why. The message box stays empty and no banner appears.

This replaces #26140, which fixed the same bug in a +1,410 / −587 production-line change across 67 files. This version is +426 / −335 across 29 files (tests excluded). Why it is smaller is explained under Why below.

What Changed

Before / after on the phone

Situation Before After
Your message, recorded by the host, then not delivered (agent failed to start, Orca restarted, agent refused it) Hidden. Depending on timing: text back in the box with a banner, a copy that looks sent, or nothing Stays where you sent it, with a red line under it giving the reason in the desktop's words. Box stays empty, no banner, no duplicate
The same, rejected after the phone was told "accepted" Vanished Stays in place with the line
A not-sent message followed by newer turns (hidden) Drawn where you sent it, not at the bottom
Another device's or agent's message the host rejected Hidden Shown with the line, as on desktop
The chat already shows a "couldn't start" or sign-in row Banner repeated the guidance The line says only "Your message was not sent."; the reason is said once, in that row
You resend the same text after a not-sent message (n/a) One new bubble, no stray copy, no false "Delivery unconfirmed". The earlier failed copy is hidden, which is the existing rule from #24710
Your message while it is on its way A temporary copy outside the chat list, removed when a row with the same text showed up The same bubble, now a row at the end of the chat (as on desktop) that turns into the host's row once recorded, matched by the message's own id
A message the host never recorded (refused before recording, disconnected) Text back in the box with a banner Unchanged
A message you took back with Stop, or one kept as a queued card Stop row / card Unchanged

Desktop: unchanged. Its rows, order and words stay the same. The desktop code that changed is shared with the phone now: the not-sent wording and the rule that decides when a pending send is done.

The mechanism

  1. The phone uses the desktop's derivation of the chat. Both clients turn the host's record into chat rows with one shared function. The phone used to pass a flag telling it to hide rejected messages. The flag is deleted, so there is one behaviour: a rejected message is always drawn in place, marked not sent. The host's conversation outline (the list of turns it serves to clients) now skips not-sent rows itself, and a test proves its output is unchanged.
  2. Not-sent rows sit where they were sent. The shared function used to append them last and relied on the reader to sort. The desktop sorts; the phone draws the list as given. They are now merged in at their record position, after stop rows. The desktop's drawn output is identical (checked on 20,000 randomly generated chats).
  3. The line under the message uses the desktop's words. The desktop's not-sent wording moved into shared code (a pure move) and returns sentence parts that each client renders. The phone reads them from the record it already receives.
  4. A message the host recorded is never handed back. When the send's own reply says the host recorded and rejected it, the phone now treats it as sent (its row holds it), the same way it already treated a message withdrawn by Stop and the same way the desktop treats every recorded answer. The banner's sign-in detail, whose only source was this path, is deleted; that guidance now appears once, in the chat.
  5. Each phone send is one record matched by its own id. Once rejected rows are visible, matching a pending bubble to its row by text breaks: resending "Hello" after a not-sent "Hello" left a stray bubble forever. The phone now does what the desktop does: each send has an id (the host already stores the message under it), the pending bubble is drawn by the shared function as a row at the end of the chat, and it is dropped in the same pass once the host's record has that id. One shared rule decides when a send is done, used by both clients. The 20-second "Delivery unconfirmed" hold and the photo previews key off the same id. Chats running in a terminal (whose transcripts carry no ids) keep matching by text, unchanged.

No wire change: no new request field, stream message or published content. A phone from this branch paired to an older host, and an older phone paired to this host, both work; see Testing.

Why

The common pattern: a message the host recorded stays in the conversation, its text is never handed back, and the reason is shown in the conversation. Only a message the host never recorded goes back to the message box. Pending copies are matched to their messages by id. The phone uses the same derivation as the main client. This change makes the phone follow it by reusing the desktop's mechanism instead of building a second one.

Why it's smaller than #26140. That PR bundled several separate things. This one keeps only the fix:

  • The phone shows rows in place, with the line: kept.
  • Order by record position: kept.
  • Matching pending sends by id: kept, but on the desktop's mechanism (one record per send, drawn and dropped by the shared function) instead of id checks added to the phone's text-matching code. That also deletes older phone code that matched queued cards by text.
  • A separate send outcome (recorded-unsent): dropped, because every caller handled it like "accepted".
  • Two new id-tracking files and a new session field: replaced by data the phone already had.
  • The failure-wording overhaul (name the agent instead of "provider", Codex refusal classification, failed-turn wording for agents using the Agent Client Protocol, a readable-text filter): not part of this fix. Both clients keep today's wording here; see Follow-ups.

Differences from the common pattern

  • Intended: the reason is a line under the message, not a separate error row. The desktop has done this since fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710. Where the host does write a row (a failed start or sign-in), the line shortens to "Your message was not sent." so the reason is said once.
  • Intended: chats running in a terminal keep matching pending copies by text; their transcripts carry no message ids.
  • Intended: the phone shows the pending bubble once the host accepts the send (the desktop shows it from the tap), and a send whose answer is lost stays hidden with a "Delivery unconfirmed" banner after 20 seconds (the desktop hands it back to the box). Both were already the phone's behaviour before this change; the phone also keeps its records whether or not a chat screen is open, and relies on the composer's sending state for one send at a time.
  • Temporary: resending the same text hides the earlier failed copy (fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710's rule, both clients). In the common pattern each message stays. Follow-up: keep the failed original when the same text is sent again, on both clients.

Follow-ups (not started, possible)

  • Wording, smallest form: "{{agent}} didn't accept this message" replaces "The provider did not accept this message" (one sentence pair and its 6 translations). Separate small PR, coming next.
  • Codex: an explicit "unwritten" refusal is currently read as a content refusal. A host classification fix; it changes what the host publishes, so it needs its own compatibility review.
  • Failed-turn wording for OpenCode, Grok and OMP (raw error text shown today).
  • A filter that keeps raw error codes out of reason text sent by older hosts.

Linked Issue

Supersedes #26140 (left open for now).

Visual Proof

Real agents (Claude Code), iOS simulator for the phone, hidden desktop window for the desktop. Before = main, after = this branch.

1. Claude refuses the message (not signed in). Before: the text went back into the message box and the chat shows no message. After: the message stays, with "Your message was not sent." under it; the reason is said once, by the sign-in row (that row appearing twice is the same on main).

Before After

2. A captioned photo is refused. Before: photo and caption went back into the message box. After: one message with the photo and caption, and the line.

Before After

3. Refused after the phone already showed it as sent (Claude never finished starting). Before: the message vanished; only the stop row stayed. After: the message stays with the line, then the stop row.

Before After

4. A not-sent message stays where it was sent (after only; main hid it): the not-sent message sits above a later delivered turn, not at the bottom.

5. Unchanged: a delivered photo on the phone, and the desktop.

Phone, delivered photo: before after Desktop, refused message: before after

Testing

Automated. Unit tests for the shared projection (placement at record position, sorted-order parity with the desktop, outline unchanged with not-sent rows), the shared not-sent wording, the shared settle rule, the phone's not-sent line, send outcome, and the new per-send records (a pending bubble and a "Delivery unconfirmed" hold settle when their record arrives; queued card, hand-off, Stop, tab switch, unmount, reload, same-text resend, photo). Reviewers also compared the desktop against main on 20,000 randomly generated chats (rows, drawing, host outline identical) and the shared settle rule against the old desktop loop on 200,000 (identical). Typecheck (desktop, main process, CLI, mobile), the mobile test-typecheck ratchet and CI pass.

Live, real agents (macOS, iOS simulator against a hidden local host; real Claude Code and Codex; every row driven by a separate non-Claude agent):

Row Result
Claude refuses (sign-in), text and captioned photo Pass. Main bounces the text back
Refused after the phone showed it as sent (start watchdog) Pass. Main makes it vanish
Not-sent message, then a newer delivered turn Pass: stays above the newer turn
Same-text resend after a not-sent message Pass: one bubble and reply, no stray copy, no "Delivery unconfirmed"
Delivered to Claude and to Codex Pass, unchanged
Send while idle, while a turn is running, right after Stop Pass: one bubble at the end, lands in place, no duplicate
Photo delivered Pass: photo on the bubble from the first frame, unchanged from main
Send then force-quit the app; same with the host paused Pass: ends as one row
Host paused ~30 s mid-send Pass: the row appears once the host records it; no false "Delivery unconfirmed"
Desktop, every chat above Same rows, order and words as main
New phone + old host, old phone + new host Pass (new phone shows the row in place; old phone behaves exactly as before)

Not reproducible live, covered by unit tests only: a send during Claude's "Stopping…" window (it lasts under half a second), a send the host never records and never answers, and the "host unreachable" path (the phone's Send is disabled while disconnected). A signed-out Codex accepts the message and fails the turn instead of refusing it, so there was no Codex refusal to test.

Found during QA, also on main, not changed here: for about a second after sending a photo that the host refuses before answering, the bubble shows the photo's temporary file path instead of the photo.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Review

Reviewed in rounds before opening: an architecture challenger that designed its own fix from the problem alone (it reached the same design, and recommended moving the phone's pending sends onto the desktop's mechanism, which this PR now does), adversarial reviewers for the shared/desktop side, the phone's rows and outcome, and the send settling, then a readiness checklist. No correctness bug above minor was found; the minor fixes are in. A PR comment lists what was fixed and deferred.

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: no new inputs or surfaces.
  • Cross-platform: phone and shared code only; no platform-specific code.
  • SSH and remote hosts: the phone talks to a remote host exactly as before; no wire change.
  • Mobile: this is the mobile change.
  • Backward compatibility: old/new phone × old/new host checked (see Testing).
  • Performance: the merge and the notices run only when a not-sent row exists; the settle step returns at once when nothing is pending.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

The shared projection appended rows the host recorded but never delivered after every other row,
leaving the reader to sort them. A reader that draws the list as given showed an old not-sent
message under newer turns. Merge them in at their journal places, after the stop rows are built,
so the list is already in order. The desktop sorts by the same order, so what it draws is unchanged.
…ords

The desktop's words for a message the host recorded and then rejected now live beside the other
rejection words, returning notice parts each client renders its own way, so the phone can say the
same thing. The desktop calls it with the same inputs; its words are unchanged.
Every reader now draws a message the host recorded and then rejected where it was sent, marked
not sent, so the rejectedInPlace option goes. The phone was its last off caller apart from the
host's conversation outline, which now leaves not-sent rows out itself; a test proves the outline
is the same as if the rejected message had never been sent.
The phone now draws a message the host recorded and then rejected where it was sent, as the desktop
does, with one small line under the bubble in the desktop's words. The line is built from the
journal the view already receives; when a start-failure or sign-in row already gives the reason,
it says only that the message was not sent. The load-earlier header moves to its own file to keep
the view under its line limit.
When the send's own reply said the host recorded the message and then rejected it, the phone put
the text back in the composer with a banner, though the host and the desktop keep it in the chat.
A recorded send now settles like any other: its row draws it in place as not sent (or its kept
card holds it), and the composer stays empty. The banner's failure-fact plumbing, fed only by that
branch, goes with it; the transcript states the sign-in guidance once.
Once a not-sent row stays in the chat, matching a pending bubble by its text breaks: resending the
same words after a not-sent message never reached the count the bubble waited for, so a stray copy
stayed, and an acknowledgement lost on that resend showed a false "Delivery unconfirmed".

A structured send is now one record, kept by the structured session under the id it is sent with.
Three parts match the desktop's sender: the id, the drawing (the shared projection draws it at the
tail until the host's row under that id lands, in the same pass) and the settle rule (the host's own
record of it, a submission, a card, or a card's hand-off, drops it by the one rule the desktop now
shares). The rest stays the phone's own: the bubble shows once the host accepts the send, not from
the tap; a send whose answer was lost is held undrawn and says "Delivery unconfirmed" only if its
deadline passes first, instead of going back to the composer; records are kept whether or not a
chat view is open; and one send in flight is the composer's rule, not the sender's. Its photo is
kept under the row id it shares with the host's row, so it shows on the bubble, the landed row and
a not-sent row. The drafts' text matching, with its older queued-card matching, now serves only
terminal-backed chats, whose transcripts carry no ids.
The merge kept a rule for rows with no journal place that no caller reaches and that the desktop's
sort does not have; drop it, and say what merging after the stop rows actually keeps. A test pins
that the list the phone draws as given is already in the desktop's sorted order, with a moved stop
row, a retry run, a cut-turn notice, a held card and two not-sent rows.
Red and capped to the bubble's width, like the desktop's line and the phone's own returned-card
caption, and only when there is a notice: the row had a second, unreachable source of words. A
send a Stop took back is no failure, so it no longer rebuilds the notices on every streaming frame.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/phone-not-sent-v2 branch from 4c840b5 to 8db7841 Compare October 12, 2026 10:11
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review and QA summary

The problem. A message sent from the phone that the host recorded but couldn't hand to the agent (start failed, Orca restarted, the agent refused it) was hidden on the phone: it vanished, bounced back into the message box with a banner, or sat as a copy that looked sent. The desktop has kept such messages in place since #24710.

What changes for you. On the phone the message now stays where you sent it, with a red "not sent" line under it in the desktop's words. Nothing is pushed back into the message box, and no banner appears. The desktop is unchanged.

How it was reviewed.

  • Architecture challenge: a reviewer designed its own fix from the problem alone before reading this one. It reached the same design. Two reviewers also pointed out that the first version matched pending sends by id inside the phone's old text-matching code. That part was rebuilt on the desktop's own mechanism: one record per send, drawn and dropped by the shared chat derivation, settled by one rule both clients now share. That part is now net +86 production lines (the first version was +106), and it deleted older code that matched queued cards by text.
  • Adversarial reviewers: three of them, one each for the shared and desktop side, the phone's rows and send outcome, and the send settling. Then a second round on the rebuilt part, a narrow third round, and a readiness checklist on the final diff.
  • Desktop equivalence: checked against main on 20,000 randomly generated chats (rows, drawing, the host's conversation outline) and the shared settle rule against the old desktop code on 200,000. All identical.

Fixed in review (all minor):

  • The not-sent line is red and width-capped, as on desktop (it was grey).
  • The not-sent line's bookkeeping skips messages withdrawn by Stop, as desktop does.
  • A fallback line that could never show was removed.
  • The 20-second "Delivery unconfirmed" deadline and its text live in one place.
  • The lost-answer timer can't start after the chat screen closes.
  • What to draw comes from React state only.
  • Two code comments and one commit message were corrected so they don't overclaim.

Deferred (minor, not user-visible):

  • With a not-sent message in the chat, the phone redoes its not-sent bookkeeping on every streaming frame. That is about 0.2 ms per 1,000 messages, the same cost the desktop already pays.
  • A pending send made just before switching chats keeps its small in-memory record until you reopen that chat. Nothing displays wrongly.
  • Already on main and unchanged here:
    • a photo send whose answer is lost drops its local preview;
    • right after sending, the phone still computes a text snapshot it no longer needs.

There are no P2 items.

Verified live (macOS, iOS simulator against a hidden local host, real Claude Code and Codex, every row driven by a non-Claude agent; see the Testing table in the description):

  • refusals, including a captioned photo;
  • refusal after the phone already showed the message as sent;
  • order relative to newer turns;
  • same-text resend;
  • sending while idle, during a running turn, and right after Stop;
  • a delivered photo;
  • force-quitting the app mid-send, including with the host paused;
  • a paused host;
  • the desktop;
  • both mixed-version pairings.

Not verified live:

  • A send during Claude's under-half-second "Stopping…" window.
  • A send the host never records and never answers.
  • The "host unreachable" path: Send is disabled while disconnected, so the phone can't reach it.
  • A Codex refusal: a signed-out Codex fails the turn instead of refusing the message.
  • Android, Windows or Linux hosts, and SSH.

These rest on unit tests and code review. Found during QA, also on main and not changed here: for about a second after sending a photo the host refuses before answering, the bubble shows the photo's temporary file path.

…t-sent-v2

# Conflicts:
#	src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts
@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4c339ea0-fada-4f16-8c08-0e6309d74780

📥 Commits

Reviewing files that changed from the base of the PR and between f1b99e1 and c32a228.


You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8636bff9-e7ab-4813-9e41-8325577a84bc

📥 Commits

Reviewing files that changed from the base of the PR and between 1951ddb and f1b99e1.


📒 Files selected for processing (1)
  • src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.



📝 Walkthrough

Walkthrough

Structured chat now projects eligible rejected submissions as unsent messages at their journal positions. Mobile structured sends receive client message IDs and use optimistic tracking for accepted and uncertain outcomes, including image previews. Mobile chat displays notices for unsent messages. Legacy queued-card matching was removed from unconfirmed-send handling, and the load-earlier control was extracted into a component.


Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to f1b99

This test-only change preserves the stopped-message ordering check, with no concrete merge-blocking risk identified.

Pre-merge checks · Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 33.96% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 53 functions across 50 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: mobile now keeps not-sent messages in place using the desktop logic.
Description check Passed The description is complete and well structured. It covers the user impact, implementation, rationale, linked issue, visual proof, testing, compatibility, performance, review notes, and checklist.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bf1aabaa-0bfd-4ab6-9ab8-0d3476ecd9f9
📥 Commits

Reviewing files that changed from the base of the PR and between c62c5a4 and 1951ddb.

📒 Files selected for processing (67)
  • mobile/src/session/MobileNativeChatMessage.test.ts
  • mobile/src/session/MobileNativeChatMessage.tsx
  • mobile/src/session/MobileNativeChatView.tsx
  • mobile/src/session/mobile-native-chat-draft-reconcile.ts
  • mobile/src/session/mobile-native-chat-list-header.tsx
  • mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts
  • mobile/src/session/mobile-native-chat-message-styles.ts
  • mobile/src/session/mobile-native-chat-pending-echo.ts
  • mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx
  • mobile/src/session/mobile-native-chat-unsent-notices.test.ts
  • mobile/src/session/mobile-native-chat-unsent-notices.ts
  • mobile/src/session/mobile-structured-agent-session-contract.ts
  • mobile/src/session/mobile-structured-agent-session-send.ts
  • mobile/src/session/mobile-structured-send-delivery.test.ts
  • mobile/src/session/mobile-structured-send-delivery.ts
  • mobile/src/session/mobile-structured-send-new-action.test.ts
  • mobile/src/session/use-mobile-native-chat-controller.test.ts
  • mobile/src/session/use-mobile-native-chat-controller.ts
  • mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • mobile/src/session/use-mobile-native-chat-message-send.ts
  • mobile/src/session/use-mobile-native-chat-send-error.test.ts
  • mobile/src/session/use-mobile-native-chat-send-error.ts
  • mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx
  • mobile/src/session/use-mobile-native-chat-turn-status.ts
  • mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts
  • mobile/src/session/use-mobile-session-native-chat-dictation.ts
  • mobile/src/session/use-mobile-structured-agent-session-send.test.tsx
  • mobile/src/session/use-mobile-structured-agent-session.ts
  • mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts
  • mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
  • mobile/src/session/use-mobile-structured-send-with-outcome.ts
  • mobile/src/session/use-mobile-structured-sends.test.tsx
  • mobile/src/session/use-mobile-structured-sends.ts
  • src/main/codex/codex-collab-call-delegation.test.ts
  • src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts
  • src/main/codex/codex-structured-question-order.test.ts
  • src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts
  • src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts
  • src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts
  • src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts
  • src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx
  • src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx
  • src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx
  • src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx
  • src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts
  • src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts
  • src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
  • src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts
  • src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts
  • src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts
  • src/shared/agent-session-conversation-outline.ts
  • src/shared/native-chat-cut-turn-notice.test.ts
  • src/shared/native-chat-provider-retry-runs.test.ts
  • src/shared/native-chat-stopped-send-turn.test.ts
  • src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts
  • src/shared/structured-agent-session-message-projection.test.ts
  • src/shared/structured-agent-session-message-projection.ts
  • src/shared/structured-agent-session-rejection-words.test.ts
  • src/shared/structured-agent-session-rejection-words.ts
  • src/shared/structured-agent-session-send-settlement.test.ts
  • src/shared/structured-agent-session-send-settlement.ts
💤 Files with no reviewable changes (1)
  • mobile/src/session/mobile-native-chat-pending-echo.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Comment on lines +102 to +122
const sends = sendsBySession[sessionKey] ?? NO_SENDS
useEffect(() => {
if (sends.length === 0) {
return
}
const { cards, recorded } = structuredAgentSessionSettledSends(
submissions,
(queuedMessages ?? []).map((message) => message.messageId)
)
const settled = new Set(
sends.map((send) => send.clientMessageId).filter((id) => cards.has(id) || recorded.has(id))
)
if (settled.size === 0) {
return
}
for (const id of settled) {
clearTimeout(deadlinesRef.current.get(id))
deadlinesRef.current.delete(id)
}
forget(settled)
}, [forget, queuedMessages, sends, submissions])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '98,145p' mobile/src/session/use-mobile-structured-sends.ts
sed -n '98,210p' src/shared/structured-agent-session-message-projection.ts

Repository: stablyai/orca

Length of output: 6180


🏁 Script executed:

rg -n -F --glob '*.ts' --glob '*.tsx' -- 'structuredAgentSessionSettledSends' mobile src
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'projectStructuredAgentSessionMessages' mobile src
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'submissions:' mobile/src/session src/shared | head -80
sed -n '1,180p' mobile/src/session/use-mobile-structured-sends.ts
sed -n '1,180p' src/shared/structured-agent-session-message-projection.ts
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'journalled' mobile/src/session src/shared

Repository: stablyai/orca

Length of output: 44094


🏁 Script executed:

sed -n '1,140p' src/shared/structured-agent-session-send-settlement.ts
sed -n '205,260p' src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts
sed -n '110,160p' mobile/src/session/use-mobile-structured-agent-session.ts
sed -n '210,250p' mobile/src/session/use-mobile-structured-agent-session.ts
sed -n '1,180p' src/shared/structured-agent-session-send-settlement.test.ts
sed -n '1,155p' src/shared/structured-agent-session-message-projection.test.ts
sed -n '220,255p' src/shared/structured-agent-session-projection.test.ts
sed -n '1,90p' mobile/src/session/use-mobile-structured-sends.test.tsx
sed -n '270,345p' mobile/src/session/use-mobile-structured-sends.test.tsx

Repository: stablyai/orca

Length of output: 20103


🏁 Script executed:

sed -n '1,180p' src/shared/agent-session-queued-submission.ts 2>/dev/null || true
sed -n '1,180p' src/shared/structured-agent-session-queued-submission.ts
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'dispatchState ===' src/shared mobile/src/session | head -100
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'isQueuedAgentJournalSubmission' src/shared mobile/src/session
sed -n '1,130p' src/shared/agent-session-journal-types.ts
sed -n '90,155p' src/shared/structured-agent-session-message-projection.ts
sed -n '1,120p' mobile/src/session/use-mobile-structured-agent-session.ts

Repository: stablyai/orca

Length of output: 19219


Use the loaded journal row as the accepted-send settlement boundary.

Retaining an accepted send in useMobileStructuredSends alone does not fix the gap. The shared projector still suppresses the optimistic entry when any matching submission exists. The submission has no message body, so the bubble remains hidden until the journal row loads.

Keep accepted, non-queued sends until their matching journal item is loaded. Preserve immediate settlement for cards, held sends, and non-accepted submissions. In the projector, allow an accepted, non-queued submission while its journal item is absent. The existing journalled check then removes the optimistic entry when the row loads, so it is never duplicated.

Suggested fix
 export function useMobileStructuredSends(args: {
   sessionKey: string
   submissions: readonly AgentJournalSubmission[]
+  journalItemIds: ReadonlySet<string>
   queuedMessages: MobileQueuedMessageFeed
   onSendError: MobileNativeChatSendErrorReporter
 }): MobileStructuredSends {
-  const { onSendError, queuedMessages, sessionKey, submissions } = args
+  const { journalItemIds, onSendError, queuedMessages, sessionKey, submissions } = args
...
     const settled = new Set(
-      sends.map((send) => send.clientMessageId).filter((id) => cards.has(id) || recorded.has(id))
+      sends
+        .filter((send) => {
+          const id = send.clientMessageId
+          const submission = recorded.get(id)
+          return (
+            cards.has(id) ||
+            (send.unconfirmed && submission !== undefined) ||
+            (submission !== undefined && submission.dispatchState !== 'accepted') ||
+            journalItemIds.has(agentJournalSubmissionKey(id))
+          )
+        })
+        .map((send) => send.clientMessageId)
     )
...
-  }, [forget, queuedMessages, sends, submissions])
+  }, [forget, journalItemIds, queuedMessages, sends, submissions])

Pass the loaded transcript item IDs from useMobileStructuredAgentSession. In projectStructuredAgentSessionMessages, replace the unconditional submission check with a check that suppresses queued or non-accepted submissions but allows an accepted, non-queued submission until journalled contains its ID.

@brennanb2025
brennanb2025 merged commit 6440d3c into main Oct 12, 2026
32 checks passed
@brennanb2025
brennanb2025 deleted the brennanb2025/phone-not-sent-v2 branch October 12, 2026 22:17
nwparker added a commit that referenced this pull request Oct 14, 2026
Main added a per-row not-sent notice (#27936) after this branch's row reuse;
it now travels in each row's reused props, so a rejection arriving redraws
that row. Row reuse also compares only plain objects and arrays by value: a
Set (the open subagent groups) keeps its contents off its own keys, so two
different Sets read as equal and a toggled group kept its stale state.
Restores the row-renderer hook and the shared message comparison the
branch's merge commits carried.
brennanb2025 added a commit that referenced this pull request Oct 26, 2026
…#23840)

* perf(mobile): a streaming reply re-renders only the rows that changed

Every structured-chat batch rebuilt the mobile transcript: the fold cloned each
historical tool run, image and echo rows rebuilt their blocks, each settled
turn's status object was rebuilt, and the list's renderItem changed identity,
so every mounted row re-rendered per batch.

Move the desktop list's by-id row reuse into src/shared and apply it to the
mobile list rows and to their derived turn props, and turn on FlatList
strictMode so a stable renderItem skips unchanged cells.

* perf(mobile): resolve native chat row props only when rows or turns change

The view re-renders on every composer keystroke; the row-props pass walked the
whole loaded transcript each time. Also drop a test that pinned a limit rather
than behavior, and a turn-status comment the row reuse made stale.

* refactor(native-chat): type the shared row reuse comparison

* perf(native-chat): compare rebuilt block lists with an index loop

Folding hands each tool-run assistant row a new block list every batch.
Walking that list as a generic object allocated Object.keys arrays for
both sides; comparing two arrays by length and element skips that.

* fix(mobile): carry the not-sent notice through the reused chat rows

Main added a per-row not-sent notice (#27936) after this branch's row reuse;
it now travels in each row's reused props, so a rejection arriving redraws
that row. Row reuse also compares only plain objects and arrays by value: a
Set (the open subagent groups) keeps its contents off its own keys, so two
different Sets read as equal and a toggled group kept its stale state.
Restores the row-renderer hook and the shared message comparison the
branch's merge commits carried.

* perf(native-chat): keep the desktop list's row comparison as it was

The deeper value comparison is only needed for mobile's re-derived rows; the
desktop projection goes back to comparing block identity.

* Restore main's files the pre-commit formatter rewrote during the merge

* Allow the FlatList strictMode augmentation's interface

* Pass FlatList strictMode through a spread instead of a type augmentation

* Measure streaming row churn on the phone page against main

* Keep unchanged phone chat row inputs stable during streaming

* Project full-message recovery context with each phone chat row

* Cover recovery updates and stale requests in streaming row contract

* Inspect row messages through their recovery provider in view tests

* Use a type-only module import in the recovery mock

* Preserve chat recovery identity and skip unchanged row derivation

* Keep transcript reuse at mounted rows and publish current recovery

* Remove duplicate React type import from transcript parity test

* Use static type imports in transcript streaming tests

* Complete the streaming recovery output fixture

* Temporarily expose failing mobile test compiler diagnostics

* Correct streaming test callback and selector types

---------

Co-authored-by: Neil <neil@stably.ai>
nwparker added a commit that referenced this pull request Oct 26, 2026
…indow's source tab (#33032)

* fix(terminal): a pane another client moves to a new tab leaves this window's source tab

When a phone, another desktop or the host moved a pane out of a split, this
window kept drawing the same live terminal in both tabs. Orphan recovery read
the source tab's binding as an orphan and kept it, and the pane reconciler
only closes panes whose PTY ended. A binding whose terminal the host publishes
in another tab is now stale rather than orphaned, and a live pane whose PTY
another tab now binds is detached with its PTY left running.

* fix(terminal): keep this window's pending pane move through pre-move snapshots; retire the moved pane's agent records

A host snapshot taken before the host processed this window's own move still
lists the terminal in the source tab, so the stale-binding rule would drop the
new tab's pane. A pending host move now exempts its target pane until it
settles. An externally moved pane's agent records are retired with the source
pane, and only leaves in a tab's tree count as binding a PTY.

* fix(terminal): an externally moved pane's cleanup never retires a target this window already moved it to

If a pre-move snapshot redraws the source pane after this window's own move,
the source key already aliases the live target; retiring it would delete the
target's agent status and launch records.

* fix(wsl): keep a UNC drive-folder repo's worktrees in its own spelling (#33706)

A repo saved as \\wsl.localhost\<distro>\mnt\c\... runs Git in WSL, which
lists its worktrees as /mnt/c/.... The listing mapped those to C:\..., so the
primary worktree got the id <repo>::C:\... instead of the saved
<repo>::\\wsl.localhost\...\mnt\c\.... Its tabs, agents and terminal history
are keyed by the saved id: the renderer dropped them as belonging to no
worktree, and the C:\ row showed up as a hidden discovered worktree.

Map drvfs output to the distro's UNC view when the repo itself is saved
under it. Repos saved as drive paths (Git routed to WSL) and in-distro repos
keep today's drive mapping.

* refactor(cloud): share push's database layer as a package (#33714)

Moves the push gateway's database layer into a shared cloud/packages/cloud-database package (service name and schema passed in). Push's behaviour, log names, SQLite file and tests are unchanged.

* refactor(renderer): group agent-status records and reducers (#33665)

* refactor(renderer): group agent-status records and reducers

* refactor(renderer): consolidate concrete type owner imports

* feat(connector): connector service skeleton that checks auth-service tokens (#33723)

* feat(tools): resize the PTY mid-capture with --resize-at (#33781)

* feat(connector): inbox with held long poll, two confirmations and expiry, on connector tokens (#33734)

* fix(codex): resume a thread with the permissions it was last given, not Orca's launch flag (#33460)

* fix(codex): resume a thread with the permissions it was last given, not Orca's launch flag

Codex restores a resumed thread's saved approval/sandbox settings unless the
launch passes a permission flag. Orca replayed its launch-time recipe
(--dangerously-bypass-approvals-and-sandbox, --approve-for-me, or the Ask
flags) on every resume, so a session switched to "Approve for me" came back
as Full Access after a restart, and that override was saved to the thread.

Fixes #10886

Co-authored-by: mys <4546173+wowpotato@users.noreply.github.com>

* test(mobile): Codex AI Vault resume no longer replays Orca's permission flag

The mobile AI Vault resume builds its command through the shared resume plan,
so it now omits --dangerously-bypass-approvals-and-sandbox like desktop does.

Co-authored-by: mys <4546173+wowpotato@users.noreply.github.com>

---------

Co-authored-by: mys <4546173+wowpotato@users.noreply.github.com>

* Clip retained panes until their anchors resolve (#33788)

* fix(agent-resume): keep a background agent launch's flags when it is resumed (#33776)

* fix(agent-resume): record a background agent launch's recipe so resume keeps its flags

* test: narrow the reveal's notifier so the test needs no cast

* refactor(agent-resume): one recipe registration path; skip adopted panes

* test: add the launch-recipe listener to the remaining window mocks

* test: remove low-value predicates, inventories and helper echoes (#33778)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* test(e2e): the moved-by-another-client spec splits a server-made tab once its shell is up

A desktop-seeded first tab in an empty paired workspace is still settling its
first spawn when a split lands right after it opens, which can leave the split
pane unbound; the spec now has the server make the source tab, as #33027's does.
Its timeout bump says why.

* Fix native chat hover colors for live theme changes (#33791)

* perf(daemon): skip drain-waiter filter when none are pending (#30996)

releaseDrainWaiters runs on every stream-socket read, but waiters only exist
while a disconnect drains. Return early when the list is empty so each read
no longer allocates a throwaway filter array and callback closure.

Co-authored-by: OrcaWin <alpha-eng@stably.ai>

* build(deps): bump vite 8.3.5, oxlint 1.89.0, oxlint-tsgolint 7.0.2005 (#33794)

oxlint 1.89 adds eslint(no-setter-return), which flagged one test setter.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* test: remove low-value inventories, passthroughs and redundant cases (#33796)

* test: remove low-value inventories, passthroughs and redundant cases

* style: format test suites after pruning redundant cases

* test: remove obsolete workflow and flaky-test references

* test: prune deleted builder test from Windows shim allowlist

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(agent-awake): warn once while the Linux lid sleep assertion keeps failing (#24839)

* fix(agent-awake): back off and warn once when the Linux lid sleep assertion keeps failing

On a headless Linux host, systemd-inhibit starts and then exits with code 1.
start() reset the failure streak right after the spawn, so every 30 s retry
logged a fresh warning and the retry never backed off.

Keep the streak until an intentional stop or until an inhibitor has held for
a full retry interval, and double the retry delay on consecutive failures
(30 s up to 30 min).

Fixes #20739

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent-awake): keep the lid assertion failure streak across idle/active cycles

stop() reset the warn-once dedupe and backoff, so a host where logind denies
the inhibitor still warned once per agent turn. Reset only after an inhibitor
was actually held for a retry interval.

* fix(agent-awake): keep the 30s lid assertion retry instead of backing off

Backoff delayed re-acquiring the lid inhibitor on laptops after transient
failures; warning once per failure streak already fixes the log flood.

---------

Co-authored-by: brynnclaw <261708852+brynnclaw@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(computer-use): probe Linux editables via get_editable_text_iface (#10586)

set_value looked up node.is_editable_text before attempt() ran. GI bindings
from at-spi2-core < 2.53 (e.g. Ubuntu 24.04) do not expose that function, so
the AttributeError escaped and every set-value failed. Probe with
get_editable_text_iface, which has been bound since 2.9.2 and checks the same
interface.

Closes #10569

Co-authored-by: bbingz <zzb@gxsmjx.com>

* fix(updater): probe latest-linux-arm64.yml on Linux arm64 (#18339)

* fix(updater): probe latest-linux-arm64.yml on Linux arm64

Preflight HEAD of the Linux updater manifest used latest-linux.yml on
every arch, so arm64 could pin a tag whose arm64 yml was still missing
(or skip a tag that was arm64-ready). Match electron-updater and probe
latest-linux-arm64.yml when process.arch is not x64.

* test(updater): accept arch-suffixed Linux manifests in updater feed mocks

On a Linux arm64 runner the feed now requests latest-linux-arm64.yml, which the
[a-z]+ suffix in these mocks rejected (12 failures with process.arch=arm64).

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* test: remove low-value renderer predicates and wrapper suites (#33811)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* refactor: colocate task page composition hooks (#33808)

* perf(lint): skip JS plugin rules on files that cannot match (#33813)

Each custom oxlint JS rule now checks the file text first and returns no
visitors when it cannot report, so oxlint skips the AST transfer to JS.
app-store-performance only resolves scopes for tracked hook names.
anti-slop rules are wrapped with per-rule text prefilters. react-doctor
rules are off for folders without React (the old cloud/** ignore never
matched: config/ globs need a leading **/).

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(computer-use): send correct named keys and hotkey letters on Linux (#22494)

* fix(computer-use): send correct named keys and hotkey letters on Linux

press_key passed the keysym for named keys (Return, Tab, arrows, Escape)
to AT-SPI as PRESSRELEASE, which expects a hardware keycode, so a
different key was pressed. Synthesize with SYM instead.

hotkey forwarded upper-case letters such as CmdOrCtrl+A to xdotool,
which reads them as shifted keysyms and sends Ctrl+Shift+A. Lower-case
single letters in key combinations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(computer-use): drop the unreachable lone-key hotkey case

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* Unify terminal starts under one host operation (#32076)

* Unify terminal starts under one host operation

* Pin winning geometry across a late terminal join

* Use terminal creation options type in launch race test

* Check closed panes after acquiring the terminal spawn lease

* Preserve terminal start resource and join boundaries

* Keep terminal attach regression within project boundaries

* Capture terminal spawn authority and preserve image coverage

* Use checked fixtures for terminal image replay regression

* test: expose persisted pane to main layout in attach race

* test: contract terminal spawn scaling and retention

* test: correct spawn scaling fixture types and CI selection

* test(terminal): cover exited prompt claims and adopted team cleanup

* test(pty): consolidate mock registry imports

* fix(pty): retain untyped IPC resume inputs in common contract

* fix(worktrees): an unqualified id two hosts share resolves to nothing, not the first copy (#33818)

A worktree id or project host setup id repeats across hosts. Lookups that
named no host picked the first match, so a delete or setup mutation could
land on the other host's copy. They now go through one sole-copy rule and
refuse when the matches span more than one host; callers that know the
host (settings Remove, ephemeral VM rollback) now name it.

Co-authored-by: fettpl <38704082+fettpl@users.noreply.github.com>

* fix(native-chat): stop retrying final startup answers (#33685)

* fix(native): pin node-pty cfset*speed with legacy baud codes for glibc 2.42+ (#33803)

* fix(native): pin node-pty cfset*speed with legacy baud codes for glibc 2.42+

glibc 2.42 versioned cfsetispeed/cfsetospeed (GLIBC_2.42) to take literal
rates and redefined B38400 as 38400U, so node-pty built on 2.42+ fails the
Ubuntu 20.04 floor gate. Pin both to the compat version alongside the
existing openpty/forkpty/pthread_sigmask pins, and pass the legacy CBAUD
code (__B38400 on 2.42+ headers, B38400 otherwise): the compat setters
reject literal rates with EINVAL, which left every PTY at speed 0.

Fixes #19420. Supersedes #19421.

Co-authored-by: MK <mkjung.github@gmail.com>

* fix(native): fail the build if pinned cfset*speed would get a non-legacy baud code

A 2.42+ header set without __B38400 would otherwise silently pass 38400U to the
compat setters and open every terminal at B0.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: MK <mkjung.github@gmail.com>

* Give native chats a fresh automatic name after /clear (#32287)

* Chat renames are saved as the chat's name, so Session History shows them after the tab closes

Renaming a native chat tab only changed that window's tab. Session History, phones and other windows read the host's saved name, so the rename vanished from History once the tab closed or Orca restarted.

A rename now goes to the host that owns the chat (agentSession.rename) and is saved as the chat's one name, the field History, tabs, sidebars and phones already read. The record remembers that the user chose it, so automatic naming never replaces it. A window's pre-upgrade tab rename is uploaded once and only fills a name Orca chose. Hosts without the new capability keep the old tab-only rename. Every native agent's History row now shows the chat's name once it has one.

* Keep saved chat names consistent across readers and reconnects

* Carry qualified chat names into cached readers

* Reuse the history recorder context across platforms

* Use recorded chat ownership for legacy name transfer

* Transfer legacy chat names only from known catalog ownership

* Correct recorder path matching and History subscription order

* Require complete evidence for bridged parity exclusions

* Preserve sparse array lengths in complete recording comparisons

* Initialize History observation before loading workspace context

* Integrate measured History recordings and scoped parity evidence

* fix chat naming retries and certify both replay modes

* fix ambiguous chat rename labels after navigation

* preserve chat rename intent across tab updates

* type dashboard snapshot fixtures without assertions

* fix: align chat name observation with store contracts

* test: update chat name consumer fixtures

* test: distinguish sender naming metadata from status

* Preserve saved Activity chat names across tab focus

* Clarify Activity tab projection identity

* Remove package-manager lockfile churn from main merge

* Refresh automatic chat names after clearing context

* fix(runtime): validate tab property edits before saving chat names

* Preserve naming eligibility and refresh cleared chat titles

* Fix checked naming projection types and logging metadata

* Supply context floor in status-feed journal fixtures

* fix: refresh History ownership after conversation clear

* fix: commit History request identity before refresh effects

* Fix History scan scope typing and integration test placement

* test: cover saved chat name reader contracts

* fix: bound chat naming work through generation settlement

* Remove duplicate History name-transition refresh

* Restore cache option in tab History test

* Consolidate naming test imports

* test: align naming fixtures with checked contracts

* test: name the discovery child fixture contract

* test: initialize writable child fixture streams

* test: account for ownership head and key reads

* fix(agents): re-list a host's terminal agents when contact returns (#33816)

* fix(agents): re-list a host's terminal agents when contact returns after a failed probe

* fix(agents): list recordKey in the detection effect's dependencies

* perf(devex): parallel pnpm lint (169s → 50s), capped typecheck threads, fresh shard timings (#33817)

* perf(devex): parallel lint, capped typecheck threads, fresh shard timings

- pnpm lint runs its 37 steps in a bounded pool (run-lint.mjs).
- tsrs defaults to 8 checkers; check:changed --maxWorkers=N passes 2N.
- ci-shard-timings refreshed from an 8-vCPU PR run (old one lacked 1,976 files).
- tc/typecheck/dev/postinstall call node directly instead of nested pnpm run.

* fix(typecheck): keep resolveTypecheckCommand args inferred for mobile's strict test typecheck

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(chat-rename): skip invisible legacy tab names during rename migration (#33678)

* fix(chat-rename): skip invisible legacy tab names during rename migration

* test(chat-rename): bound legacy migration reconciliation work

* fix(terminal): an externally moved pane's agent records follow it to its new tab

The reconcile hands the source pane's status, unread marks, launch and sleeping
records to the pane that now binds its PTY (transferAgentPaneAuthority, as a
local move does) instead of retiring them; a key this window already moved is a
no-op transfer. A throw while drawing this window's own move releases its
pending-move mark, and the reconcile effect lists the tab ids it reads.

* Revert "fix(native-chat): preserve Claude approval choices across model chang…" (#33823)

This reverts commit 4833b736f713b3f94ed12396e637712a70ad3a5e.

* Stop workers from taking over the Run they work for (#29008)

* fix(orchestration): refuse run-use by a worker on the Run it works for

A worker holding an active Dispatch in a Run could bind that Run as its coordinator.
That silently unbound the real coordinator, remembered the worker's address as a
coordinator address (so the worker's own mail in that Run routed to the Run mailbox
from then on), and when the worker later bound its own sub-Run, left the parent Run
with no coordinator at all.

bindRun now refuses a caller whose handle holds an active Dispatch in the Run, with
worker_cannot_coordinate_own_run, before any effect. Creating its own Run for
sub-workers is unchanged.

* fix(orchestration): tell a worker naming its own Run where its mail is

Workers that made a sub-Run ran `check --run <parent>`, were told "This
coordinator terminal is bound to X, not Y", and ran run-use on the parent
to "switch back", unbinding their coordinator. That refusal now says the
Run belongs to their coordinator, that their own mail arrives through
plain `check`, and that they report with `send`; it never mentions run-use.

The run-use refusal now matches the caller the way check and send do
(address, then pane, so a reminted terminal handle is still caught),
also covers Runs above the caller's own through recorded creator_dispatch_id
lineage, skips context-only self-dispatches so a coordinator can still
rebind its own Run, and passes its code through the RPC wire.

* feat(orchestration): refuse run-use over a live coordinator without --takeover

run-use silently took a Run from whoever coordinated it, even a chat or
agent that was still running. It now refuses with run_coordinator_live
when there is positive evidence the current coordinator is running (its
chat is still reachable, or the agent hooks prove its terminal agent
live) unless the caller passes --takeover. Anything unproven (closed chat,
exited or unhooked terminal, another host) rebinds as before, so restart
handoffs need no flag. The probe is async in the RPC handler; bindRun
enforces it only if the Run is still bound to the probed party.

A caller working a Dispatch can never take over, with or without the
flag (worker_cannot_take_over_run). The help text now says run-use is
for resuming a Run and that workers never use it.

* fix(orchestration): stop pointing workers at run-use

Workers learned run-use from error text: "No Run is bound. Use run-create
or run-use first", "Rebind with orchestration run-use", and "Rebind this
terminal with orchestration run-use". Each now says what the caller can
do instead. An unbound worker is told its mail arrives through plain
check and it reports with send; a displaced coordinator is told to stop
and take the Run back only if the user asks. The orchestration skill now
says run-use resumes your own Run, --takeover needs the user's word, and
workers never run it.

* fix(orchestration): stop routing mail to addresses that grabbed a Run's seat

Every address that ever bound a Run is remembered as one of its
coordinator addresses, so mail to it routes to the Run mailbox. When a
worker bound the Run it was working for, its own address joined that
list for good, and its direct mail in that Run kept landing in the
coordinator's inbox. The live database has 45 such rows since
2026-08-20.

All four readers of the remembered addresses now skip one that was first
bound while it worked an unsettled, delegated Dispatch in the same Run.
It is derived from existing rows, so no migration or delete is needed and
rows written by older builds stop mattering. The active-Dispatch check
also matches a worker's orca_session_id spelling, not only its handle.

* fix(cli): keep the orchestration spec file within its line limit

* fix(orchestration): read remembered coordinator Run ids without a type assertion

* fix(orchestration): compare seat-grab times at whole seconds

first_bound_at has no milliseconds, so a worker bound in the same second its
Dispatch settled was counted as grabbing the seat. Compare completed_at at
whole seconds with a strict >.

* fix(orchestration): a worker coordinates only Runs it created

The worker refusals depended on proving the target's coordinator alive, so
a worker could still take any Run whose coordinator was closed, unhooked,
remote or headless. Replace them with one rule derived from existing rows:
a caller working a Dispatch may bind only a Run it legitimately
coordinated before (a remembered, non-seat-grab address of that Run, or a
Run it dispatched work into), never the Run it works for, and never with
--takeover. A same-binding no-op is never refused, and workers are
classified before the legacy gate so they never see its
--takeover-legacy hint. The refusal names the Dispatch and how to end it
with worker-abandon if it is stale. The ancestor walk is gone.

run-use now records which coordinator it probed. If the binding moved
before the transaction, it probes again (three tries) and then refuses
with a retryable run_binding_changed instead of binding over a party
nobody probed. run_coordinator_live names the chat by its title and
worktree, or the terminal by its title and worktree, says it is still
open, and that closing it clears the refusal.

* fix(orchestration): say what happens to mail on takeover and stop naming run-use to readers

The help now says --takeover replaces the coordinator and replays its
unacknowledged mail to you. A replaced mailbox reader is told only that
another session coordinates the Run and to stop reading it.

* refactor(orchestration): throw one non-null refusal from the live gate

* refactor(runtime): host the coordinator presence probe beside the hook presence seam

Main moved the orphan-adoption runtime file; keeping the one-line probe next
to checkHookAgentPresenceFn leaves this PR off that file.

* fix(orchestration): use findLast for the worktree name

* refactor(runtime): move the coordinator presence probe beside the pty presence recheck

Main is moving the runtime construction files; the probe now sits with
recheckHookAgentPresenceForPty, which reads the same seam, so this PR stays
off the files being moved.

* fix(orchestration): keep workers out of their own Run seat

* fix(orchestration): clarify worker Run handoffs in skill

* test: align explicit Run gate refusal wording

* Move coordinator presence beside terminal presence checks

* Walk complete worker creator lineage when binding Runs

* Restore main documentation after merge hook formatting

* perf(orchestration): check run bindings without terminal snapshots

* test(orchestration): narrow SQLite instrumentation before access

* test(orchestration): import the explicit RPC methods module

* test: cover orchestration restart and continued Run handoff

* test: place orchestration CLI integration in CLI project

* test(cli): use explicit module extensions in restart handoff test

* test(orchestration): isolate source CLI integration in subprocesses

* refactor(orchestration): leave the live-coordinator takeover rule to #29292

#29008 is the worker fix: a worker cannot bind the Run it works for or an
ancestor, cannot abandon itself to get around that, and addresses it held while
grabbing a seat stop redirecting its mail. The separate rule that refuses any
second coordinator while the first is still open (the presence probe, the
--takeover flag and the run_coordinator_live / run_binding_changed refusals)
moves to #29292, which models Run ownership and hand-over and already builds on
it. run-use binds synchronously again, as on main.

* test(orchestration): cover delegated worker legacy takeover refusal

* test(orchestration): explain source CLI integration timeout

* perf(mobile): a streaming reply re-renders only the rows that changed (#23840)

* perf(mobile): a streaming reply re-renders only the rows that changed

Every structured-chat batch rebuilt the mobile transcript: the fold cloned each
historical tool run, image and echo rows rebuilt their blocks, each settled
turn's status object was rebuilt, and the list's renderItem changed identity,
so every mounted row re-rendered per batch.

Move the desktop list's by-id row reuse into src/shared and apply it to the
mobile list rows and to their derived turn props, and turn on FlatList
strictMode so a stable renderItem skips unchanged cells.

* perf(mobile): resolve native chat row props only when rows or turns change

The view re-renders on every composer keystroke; the row-props pass walked the
whole loaded transcript each time. Also drop a test that pinned a limit rather
than behavior, and a turn-status comment the row reuse made stale.

* refactor(native-chat): type the shared row reuse comparison

* perf(native-chat): compare rebuilt block lists with an index loop

Folding hands each tool-run assistant row a new block list every batch.
Walking that list as a generic object allocated Object.keys arrays for
both sides; comparing two arrays by length and element skips that.

* fix(mobile): carry the not-sent notice through the reused chat rows

Main added a per-row not-sent notice (#27936) after this branch's row reuse;
it now travels in each row's reused props, so a rejection arriving redraws
that row. Row reuse also compares only plain objects and arrays by value: a
Set (the open subagent groups) keeps its contents off its own keys, so two
different Sets read as equal and a toggled group kept its stale state.
Restores the row-renderer hook and the shared message comparison the
branch's merge commits carried.

* perf(native-chat): keep the desktop list's row comparison as it was

The deeper value comparison is only needed for mobile's re-derived rows; the
desktop projection goes back to comparing block identity.

* Restore main's files the pre-commit formatter rewrote during the merge

* Allow the FlatList strictMode augmentation's interface

* Pass FlatList strictMode through a spread instead of a type augmentation

* Measure streaming row churn on the phone page against main

* Keep unchanged phone chat row inputs stable during streaming

* Project full-message recovery context with each phone chat row

* Cover recovery updates and stale requests in streaming row contract

* Inspect row messages through their recovery provider in view tests

* Use a type-only module import in the recovery mock

* Preserve chat recovery identity and skip unchanged row derivation

* Keep transcript reuse at mounted rows and publish current recovery

* Remove duplicate React type import from transcript parity test

* Use static type imports in transcript streaming tests

* Complete the streaming recovery output fixture

* Temporarily expose failing mobile test compiler diagnostics

* Correct streaming test callback and selector types

---------

Co-authored-by: Neil <neil@stably.ai>

* perf(build): cap Rolldown threads on large hosts; build pairing web client in the background (#33828)

Rolldown defaults to 1.5x physical cores Tokio workers plus a Rayon pool per
logical CPU (24+24 here). Capping both at 8 on >8-core hosts cuts electron-vite
build CPU 24% with byte-identical output. pnpm dev no longer blocks on a stale
pairing web build; it stages into out/.web-next and swaps on success.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(native-chat): remove misleading usage-limit Continue action (#33392)

* fix(native-chat): remove misleading usage-limit Continue action

Keep limit warnings, reset times and provider details without offering an immediate continuation or adding manual continuation guidance. Preserve restart-interruption Continue and provider detection.

* fix(i18n): sync runtime catalog for removed notice fallback

* fix(i18n): remove unused usage-limit action translations

* test(native-chat): cover historical usage-limit wording

* test: admit historical notice fixtures through journal reader

* test(mobile): admit the old-host usage-limit fixture through the journal reader

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* Fix Codex goal Stop and selected model settings (#33631)

* Fix Codex goal Stop and selected model settings

* Preserve settings evidence and captured Stop outcomes

* Preserve refused goal pauses and bound Stop retry diagnostics

* Use checked settings state in goal contract tests

* Narrow captured Stop child and type its timing fixture

* Coordinate Stop timing fixture with deferred phases

* Fix typed mobile warning icon assertion

* Align Stop tests with reconciled controls and owned shutdown

* Check integration attach parameters without a type cast

* Match restart offer close calls to optional observer contract

* test(vitest): give each run its own temp dir and remove it on exit (#33834)

Tests leak os.tmpdir() fixtures broadly (8.6k dirs from repeated prefixes on
one shared dev host; orca-runtime-rpc alone 2,729). run-vitest.mjs now points
TMPDIR/TEMP/TMP at a marked per-run dir, removes it after Vitest exits
(including on SIGINT/SIGTERM), and sweeps marked dirs whose launcher died.
On POSIX a root longer than 50 chars (macOS /var/folders) falls back to /tmp
so Unix socket paths stay under sun_path limits.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(terminal): repaint local TUIs over the hidden-output loss banner (#33835)

Local panes printed '[Orca skipped hidden terminal output because main
recovery was unavailable.]' inside idle Codex/Claude Code frames and it
stayed until a workspace swap, because the one-row redraw nudge from
#31482 only ran for remote PTYs. Differential TUIs never rewrite cells
they believe unchanged, so the banner row persisted. Local PTYs now get
the same nudge, restoring the row on a timer since local main sends no
desktop-fit confirmation.

* fix(daemon): give each macOS resolver health probe a unique clientId (#33833)

Concurrent new-PTY resolver probes shared the 'resolver-health-check'
clientId, so the daemon evicted the older probe's control socket and that
probe waited out the 3s timeout before its terminal could spawn. Use a
per-probe UUID clientId and settle as 'unknown' as soon as the probe
socket closes.

Fixes #27751

Co-authored-by: outof-place <contact@outofplace.space>

* perf(wsl): relay orcad sockets through bash instead of a Node bridge (#33820)

Each desktop socket to a WSL host's orcad, and the liveness watch, ran a
Node process in the distro just to copy bytes (~42-50 MB RSS each). A bash
/dev/tcp relay does the same with bash and two cats (~4.5 MB). The Node
bridge stays as the fallback for hosts without bash 4.3+ or /dev/tcp and
for refused dials, so refusal and missing-runtime signals are unchanged.
SSH hosts keep the Node bridge.

* perf(orcad): retire an idle session scanner after a minute on host servers (#33845)

Only desktop requests reach a host's scanner child, which holds ~60 MB of
RSS even idle, and the 10-minute default kept it alive most of the time.
orcad now sets a 60 s idle timeout; the desktop keeps its default.

* perf(dev): −1.2 GB idle RSS per pnpm dev instance (Rolldown threads + mimalloc purge) (#33836)

* perf(dev): size the dev server's Rolldown runtime for many parallel instances

~93% of idle electron-vite RSS is Rolldown's native mimalloc arenas. The dev
wrapper now gives electron-vite MIMALLOC_PURGE_DELAY=0 and 4 Rolldown/Rayon
threads; a serve-only renderer plugin strips exactly those keys before
Electron spawns, so dev Orca's terminals never inherit them.

* fix(dev): add the runtime-env plugin only when the dev wrapper set its keys

Keeps createDevServeConfig returning the renderer config unchanged when there
is no port and nothing to strip, as electron-vite-output-contract pins.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* test(vitest): node-runtime worker exits when Vitest's IPC channel closes (#33848)

A killed Vitest left its node-runtime worker running the rest of the file
(still alive after 150s). Exit on 'disconnect' with process.exit captured
before Vitest replaces it with a throwing guard.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(terminal): a moved pane keeps its records when its source is parked, and a committed move stays fenced until its snapshot

Every host frame the mirror reconciles now hands a pane another client moved to
its new tab's key, whether or not its source tab is mounted; the stale-binding
rule lives in one module both paths use. This window's own move the host
committed stays pending until a host snapshot shows it in its new tab, since the
reply can beat that snapshot and an older one must not drop it.

* fix(preflight): detect Grok installed in ~/.grok/bin (#25324)

Grok's installer defaults to ~/.grok/bin and only symlinks into
~/.local/bin when that dir is already on PATH. A GUI-launched Orca never
looked in ~/.grok/bin, so the Agents page reported Grok as not installed.

Append ~/.grok/bin after ~/.vite-plus/bin in the three lists that must
stay in step: the packaged PATH seed, the system install-dir lookup
fallback, and the POSIX/WSL guest fallback prelude.

Fixes #17590

Co-authored-by: brynnclaw <261708852+brynnclaw@users.noreply.github.com>

* fix(terminal): answer queries in output a paused paired view discards (#33783)

* fix(terminal): answer queries in ACK-pending output a paused paired view discards

Refs #32944

* fix(terminal): capture yielded-query ownership at ingestion and cap reply age

Refs #32944

* fix(terminal): settle yielded queries by their own end seq across split frames

Refs #32944

* test: use a fixed timestamp in the yielded-query responder test

* test(cross-version-wire): stub yielded-query runtime methods on the host runtime

Refs #32944

* fix(terminal): a pane moved to a new tab stays moved on paired servers (#33027)

* test(e2e): a pane moved to a new tab on a paired server workspace stays moved

* fix(terminal): keep a pane moved to a new tab on paired runtimes

Moving a split pane to a new tab in a paired-runtime worktree changed only
the window store. The host never learned about the move, so the next host
snapshot rebuilt the old tab and the pane snapped back.

Add a session.tabs.movePaneToNewTab RPC that commits the move in the host
session through planTerminalLeafMove and republishes the session tabs.
For a mirrored host tab, the renderer applies the move first under the
mirrored id the host snapshot will use, then asks the host to commit it.
An older host or a refused move falls back to the old local-only move.

Fixes #32939

* fix(terminal): commit a host pane move through the existing leaf-move writer

The layout-writer ratchet allows no new main-process layout writers. The
headless host now hands the move to commitLeafMoveAndRekey, the same
commit a local window uses, and keeps only the window check, validation,
rehydrate and notify.

* fix(terminal): gate the host pane move on a host capability

An older host keeps today's window-only move under a local tab id instead of
a mirrored one it never confirms. Adds a terminal.list check to the e2e.

* test: drop type assertions from the pane-move tests

* fix(terminal): the host keeps a moved pane's place, focus and live terminal

After the host commits the move, the window gives it the drop position and
focus through the existing tab move and activate calls; a refusal redraws the
host's tabs. The host moves the live PTY's surface with its pane and checks the
target tab id. The move lives in its own runtime module.

* fix(terminal): hold a moved pane's drop order until the host has it

The host's snapshot of the commit goes out before its reply and appends the
tab, so the window records the drawn order as a reorder intent at the drop and
sends that order, and the focus read then, once the host answers.

* fix(terminal): route the moved tab's reorder to its owning host

The follow-up names the worktree's host instead of reading the active-server
setting (focus-read ratchet); the e2e narrows host rows to terminal tabs.

* fix(terminal): the host places a moved pane's tab in the move itself; a refusal reverts

The move carries the drop's group and index (optional; an older host appends)
and the host places and selects the tab before its one publish, replacing the
follow-up reorder and the drop-time order hold. A refused move closes the tab
this window drew without stopping its terminal and redraws the host's tabs even
when unchanged. E2e covers a same-group drop and a refusal; host unit tests cover
same-group and cross-group placement.

* fix(terminal): a refused pane move keeps its pane records; drop index in host order

The revert hands the pane's status and launch records back to the source tab
before closing the drawn tab. The host turns a projected client's drop index
into its raw order with translateProjectedSessionTabMove, as session.tabs.move does.

* fix(terminal): only an unknown drop group appends a moved pane's tab

The host catches only target_group_not_found and invalid_tab_order from the index
translation and rethrows anything else.

* fix(terminal): settle a pane move by what the host holds when its reply is lost

A lost reply is not a refusal: the window asks the host whether it holds the
pane in the new tab before undoing anything, and waits for the next snapshot
when it cannot ask. A move the host committed but this window could not draw
takes the host's tabs.

* fix(terminal): a lost transport on a pane move is unknown, not a refusal

runtimeEnvironments.call answers a dropped or overloaded transport with an error
envelope; those codes (the shared recoverable set) now ask the host instead of
undoing a move it may have committed.

* test(e2e): pane-move spec waits on real signals, not delays

The source tab is created on the server and split once its shell is listed, so
the split no longer races a desktop-seeded tab's first spawn (the CI failure).
The stay check follows output from the moved pane; the refusal check waits for
the drawn tab to appear and go.

* test(e2e): read the refused move's tab counts without Reflect (anti-slop)

* fix(terminal): a moved pane's tab joins the order the user had, not a rebuilt one

The host inserts the new tab into the group order captured before the commit,
appending without a drop position, so editors and pages keep their places. The
shared group-move fallback is no longer used here.

* fix(terminal): a moved pane's groups hold the host's current tabs, in the user's order

Placement takes membership from the rebuild after the commit and only the order
from before it, so a tab opened while the commit was pending stays (after the
user's tabs) and no stale id is written.

* fix(terminal): a moved pane's placement keeps a reorder made while it committed

The order is read after the commit, from the host's current tabs, not before it.

* fix(terminal): a moved pane keeps the lone group the user merged into

A rebuild names a single group with the default id; placement keeps the user's
id for it, so the drop position still applies after a split was merged back.

* fix: import error classification from its new shared/remote-runtime path

* fix(terminal): a pane or divider drag survives focus moving inside the window

The drag's window blur listener was registered in the capture phase, so it also
heard every element blur. A terminal attach or host snapshot that moved focus
between panes mid-gesture ended the drag; in the paired pane-move e2e on CI it
cancelled the drag before the drop. Only the window losing focus ends it now.

* test: drive the focus-change drag test through a real pane manager, without casts

* fix(terminal): a headless host moves a pane with its live terminal and never empties its source

The host names the PTY it holds for the pane, so a session copy an SSH respawn
left with an old id no longer refuses the move as a mismatch. A move that would
leave its source tab with no pane (a sibling closed meanwhile) is refused, since
no headless cleanup retires the emptied tab and it would publish as a phantom.
The e2e fences its stays-moved check on a later server rename instead of
sampling on a timer.

---------

Co-authored-by: Ben Grynhaus <ben.grynhaus@wiz.io>

* fix(terminal): a moved pane's records move only for a host snapshot this window accepts

The transfer for a pane another client moved now runs where a host snapshot is
accepted, reading the bindings from before it applies, so a delayed older list
reply that the window drops moves nothing. A move whose snapshot arrived before
its reply is not held again by that reply.

* fix(wsl-host): tie each WSL host record to its pairing key (R31) (#29832)

* fix(wsl-host): bind a WSL host record to its pairing's server key (R31)

A valid but stale record could overlay WSL hosting onto a different loopback server re-paired
under the same environment id and bridge port. The record now carries the paired orcad's host key;
the overlay ignores a record whose key differs from the pairing's. Add writes the key and a
pairing refresh rewrites it with the ports, so a relaunch that changes the key still matches.
Records written before carry no key and keep port-only binding until their next refresh.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(wsl-host): validate a refreshed pairing before writing, and accept the previous key mid-refresh

Review: the refresh wrote the record's new key before validating the pairing, so a refused or
interrupted refresh could leave a real WSL host without its overlay. Validate first, and keep the
replaced key as previousPairingPublicKeyB64 so the record stays bound across the two writes.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(wsl-host): use a key distinct from the fixture's own in the R31 overlay tests

Main gave the WSL fixture its own pairing key (#30676), the same one these tests used as the
'other server' key, so both cases inverted after the rebase. Production code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* test: remove low-value renderer suites and redundant shared test cases (#33819)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(terminal): typing held for a starting agent still arms recovery when its connect hangs (#33865)

* fix(agent-awake): warn once and back off when the sleep assertion keeps failing (#33841)

* fix(agent-awake): keep sleep-assertion failures warn-once across respawns

A successful spawn reset the failure streak, so a systemd-inhibit or caffeinate that
exits right away re-warned on every 30s retry.

* fix(agent-awake): back off sleep-assertion retries after consecutive failures

* test(agent-awake): dispose the real-timer assertion

* perf(daemon): cap the terminal daemon's young generation (−51 MB idle, −75 MB after bursts) (#33837)

* perf(daemon): cap the terminal daemon's young generation

Idle terminal polling in the daemon allocates a few MB/s of short-lived
strings (process-table captures), and V8 answers by growing the young
generation to its 64 MB semispace maximum. Nothing is retained (heapUsed
returns to ~12 MB after GC), but the committed semispaces held ~120 MB of
resident memory per daemon. Launch the daemon with --max-semi-space-size=4
on the fork, direct and extracted-AppImage paths. The flag goes on the
command line, not NODE_OPTIONS, because PTYs inherit the daemon's env.

* perf(daemon): pass the young-generation cap through the macOS launchd job

Release macOS builds start the terminal daemon from the signed helper bundle
through a launchd job, not the fork path, so the flag must be in its
ProgramArguments too.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* perf(terminal): relax idle process inspection for local POSIX panes too (#33829)

A visible local shell pane with no agent evidence was inspected every 2 s
forever. Each inspection is a full-system `ps -axo ...,tty=,lstart=,command=`
capture in the terminal daemon (0.155 s of CPU at ~1,100 processes, ~1.15 s
at ~1,950), which also churned ~2.7 MB/s of short-lived strings and grew the
daemon's young generation to ~120 MB resident. Apply the existing no-evidence
cadence (15 s while quiet; output, title or hook activity re-arms 2 s for
10 s; agent evidence returns to the active/idle tiers) to local POSIX panes,
as it already is for Windows and remote panes.

Co-authored-by: m4air <m4air@Mac.localdomain>

* perf(build): keep bundled scripts one-byte in V8 to cut idle memory (#33786)

V8 stores a script source as UTF-16 when any character is above U+00FF, so a
single em dash in a hot chunk doubled that chunk's resident source. Emit ASCII
(escaped) output for main, preload, renderer and worker bundles, escape the
CJK sentence marks in the prose URL regex source, and fail the renderer
boot-graph check if a boot chunk regresses to two-byte.

Co-authored-by: m4air <m4air@Mac.localdomain>

* perf(editor): stop Monaco's TS/JS worker when no TS/JS model is open (#33810)

Monaco's TypeScript WorkerManager has no idle stop, so viewing a single .ts
file kept a ~40 MB language worker alive for the rest of the session. After
60 s with no typescript/javascript model, re-apply the current compiler
options, which makes Monaco dispose the worker; the next TS/JS model starts
a fresh one lazily.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(runtime): stop a crash-zeroed server list from blocking every server save (#33864)

* fix(runtime): recover from a NUL-filled server list instead of blocking every save

Fixes #18766

* fix(runtime): recover only crash-zeroed server lists; keep removal proofs fail-closed

* fix(runtime): copy the blank server list aside on write instead of renaming it on read

* fix(runtime): recover a crash-zeroed server list only when saving a server

Plain reads stay fail-closed so startup cleanup cannot treat the zeroed file as
'every server was removed'; saving copies the bytes aside and publishes an empty
store only if the file is still blank.

* fix(runtime): publish the recovered server list only together with the saved server

A failed save now leaves the zeroed file in place instead of an empty registry that
startup cleanup would read as 'every server was removed'.

* fix(runtime): let SSH and WSL server setup save over a crash-zeroed server list

Their duplicate and conflict pre-checks read the list before the add and threw on the
zeroed file. The blank-file backup is now taken only once the write is attempted.

* test(runtime): give the fsynced interleaving property test room on slow disks

* test(runtime): run three guard-killing seeds instead of raising the interleaving timeout

The fsync on every store write made 40 seeds x 40 steps slow; seeds 1, 6 and 8 each
fail when one of the three guards this test protects is removed.

* fix(terminal): don't rewrite live bytes a renderer hydration seed already painted (#33766) (#33880)

Main seeds its terminal model from the renderer's xterm and queues live
chunks behind the seed. The renderer keeps parsing those chunks while it
serializes, so they were in the seed and then written again, doubling TUI
frames and splitting escapes (literal ';242m'). Hidden-pane reveals repaint
from this model. Skip queued chunks at or below the seed's seq and carry
any unfinished escape into the next chunk.

* test: remove low-value renderer helper and presentation tests (#33876)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* feat(serve): keep macOS serve out of the Dock and opt in to the GPU on Linux (#33878)

* feat(serve): keep macOS serve out of the Dock and opt in to the GPU on Linux

On macOS, orca serve now runs as an accessory app with no Dock icon or
Cmd-Tab entry, and gets them back only while it hosts a desktop window.
On Linux, ORCA_SERVE_GPU=1 keeps hardware acceleration on for hosts that
expose a GPU to the server.

* fix(serve): restore the Dock only for real launches, after window services resolve

Gate the restore on any background or E2E launch, since dock.show() activates
the app, and run it just before the window is created so a services failure
cannot leave a windowless Dock tile. Say in the docs what the GPU opt-in can't
do on Orca's own Xvfb.

* refactor: colocate task page list presentation rules (#33830)

* fix(build): pin the native SwiftPM build system for the computer-use universal binary (#21146)

SwiftPM 6.4 (Xcode 27) defaults to the swiftbuild build system, which writes
products to an xcodebuild-style layout instead of .build/<triple>/release, so
the lipo step in build-computer-macos.mjs finds no inputs. Pass
--build-system native to both per-triple builds.

Fixes #21145

* fix(ssh): refuse a second Orca server from a host cloned with the same machine-id (#33775)

* fix(ssh): keep machines cloned with one machine-id off each other's Orca server data (#32918)

Machines cloned from one image share /etc/machine-id. On a shared home both
got the same machine key, so both managed servers ran in ~/.orca, and each
judged the other's PID locally and reclaimed its live instance lock.

- Record the claiming hostname in the shared-home claim and machine homes;
  a Linux machine whose machine-id matches but hostname differs gets its own
  machine home. Older claims fall back to data-root owner records.
- orcad's instance lock no longer treats a holder with this machine-id but
  another hostname as local, and refuses to start when an older clone's
  profile-state owner record shows it is using the data root.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): keep a clone's established machine home, and let a host renamed within its boot restart

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): call a machine a clone only with positive evidence

A renamed host keeps its boot and a local disk has one machine, so only
another host and boot with this machine-id on a shared filesystem counts as
a clone, on Linux only. When both clones already write the login home, stay
and let the server lock refuse the second writer. Drop the machine-home
owner markers.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): refuse a second server from a cloned host instead of relocating data

Drop the inferred clone relocation. orcad refuses to start when a holder or
profile-state owner on a shared filesystem reports this machine-id from another
hostname and boot, naming that host and the fixes. Add ORCA_MACHINE_NAME, an
explicit host-env name that gives a managed SSH host its own machine home.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): judge cloned machines by boot and freshness, not hostname

A peer with this machine-id is another live machine only when its record is
from another boot, was written after this boot began, and is fresh. The orcad
lock now heartbeats its mtime. That drops the hostname test, which renames
broke and same-hostname clones evaded. It also drops the participant-owner
precheck that stale records could block forever, and keeps profile-state
owner cleanup from deleting a live clone's owner. stale-lock reclaim uses the
same rule. ORCA_MACHINE_NAME now rejects invalid names and keeps a host on
the home its running server or own machine-id already uses. Hints are
role-specific and aimed only at the clone.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(orcad): stale-lock reclaim waits out the lock freshness window

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(orcad): log a failed lock heartbeat and say a crashed clone's lock frees itself

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(orcad): align reclaim help, age evidence and clone hint with the freshness window

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): ORCA_MACHINE_NAME always selects its own home

Inferring which home to keep from running owners flipped after shutdown or
reboot and couldn't see macOS boots. A name now deterministically selects
its home, and a multi-line name is refused.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): refuse a whitespace-only ORCA_MACHINE_NAME, and pin reclaim test clocks

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* revert(terminal): an externally moved pane retires its source records again

Back to the reviewed behaviour: the source tab's agent records are retired and
the host republishes the agent under its new tab. Keeps the independent fixes:
a committed move stays fenced until a host snapshot shows it (confirmed on the
raw frame, and not re-held by a reply that arrives after its snapshot), the
draw-before-reply release on a throw, and the reconcile effect's tab ids.

* perf(ci): overlap preflight's serial tail; parallel ratchets; allow 2-10 unit shards (#33887)

- Plan unit selection starts after setup (overlaps lint) instead of last.
- Localization extraction starts with the typecheck; test-file-runners is background.
- run-ratchets.mjs runs ratchets in a pool (19s serial -> 8s locally).
- pnpm lint uses CI's single-parse check-import-boundaries.mjs.
- ORCA_UNIT_FULL_SHARD_COUNT accepts 2-10 (default stays 5).

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(github): send issue bodies to gh over stdin instead of argv (#25215)

Issue create (including the empty-body fallback and follow-up PATCH),
issue body edits, and issue comments passed the body as
`--raw-field body=...` on argv. Windows caps the command line at
32,767 chars, so bodies with pasted inline images failed with
`spawn ENAMETOOLONG` before reaching GitHub.

These calls now use `gh api -X <METHOD> <endpoint> --input -` with a
JSON payload on stdin. ghExecFileWithScopeAsync also now forwards
`options.stdin` to the child process; it was previously dropped.

Fixes #15832

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(open-in): strip the AppImage runtime env from editors launched on Linux (#31675)

Open in started editors with Orca's full environment, so an AppImage
launch leaked ARGV0/APPDIR/APPIMAGE/OWD/APPIMAGE_LIBRARY_PATH and the
mount-path PATH and LD_LIBRARY_PATH entries into the editor's integrated
shells (zsh then runs host commands like cargo under the AppImage name).

Add envWithoutAppImageRuntime() next to removeAppImageRuntimeEnv and pass
its result to every editor spawn path on Linux; macOS and Windows keep
inheriting the env unchanged. The clipboard publisher and both AppImage
daemon command builders reuse the same helper instead of their own
copy-and-scrub loops.

Fixes #31455

Co-authored-by: Neil <neil@stably.ai>

* fix(automations): step schedule scans by calendar day so DST changes don't skip dates (#19086)

The RRULE day scan stepped local midnight by a fixed 24h. Walking back
over a 23-hour spring-forward day landed at 23:00 two dates earlier, so
the latest-occurrence lookup used for catch-up runs skipped a date. It
returned a run a day too old for daily schedules, and a week too old for
weekly ones.

Step with setDate via a shared addLocalDays helper, in both the RRULE
scan and the cron feasibility scan. Add a regression test for
America/New_York and Australia/Lord_Howe. The test is skipped on Windows,
where unstubbing TZ doesn't restore the OS zone.

Co-authored-by: liutian <liutian.6000@bytedance.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(orchestration): keep the transcript handle open when a forward page fills (#32894)

Returning early from the for-await over the forward-page ReadStream
destroyed the stream, which closed the shared FileHandle before the
boundary checkpoint read, so any forward page that hit its limit failed
with transcript_parse_failed. Scan with positional handle.read calls
instead so the handle stays open and read errors reject inside the
caller's try/finally.

Fixes #32892

Co-authored-by: Kim Kaznowski <kim@doubledashstudios.com>
Co-authored-by: Neil <neil@stably.ai>

* fix(browser): accept an empty --value in fill and select commands (#16030)

`orca fill --value ""` and `orca select --value ""` were rejected client-side
with "Missing required --value" even though the browser.fill/browser.select RPC
schemas already accept an empty string. Read --value with the existing
getRequiredStringFlagAllowingEmpty helper so an empty value clears a field or
picks the blank option, while an absent --value is still rejected.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(telemetry): send an errno code, not the raw message, in agent_hook_install_failed (#25287)

agent_hook_install_failed sent up to 200 chars of the raw installer error,
which embeds home-directory paths (and so the OS user name) and can echo
config contents. Send only the error's errno code or 'unknown', and enforce
that in the event schema. The anchored, capped errno pattern now lives in
src/shared/errno-code.ts and is shared with the renderer launch probe and the
file-write precondition instead of their own copies.

Fixes #21492

Co-authored-by: brynnclaw <261708852+brynnclaw@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* fix(notifications): use the real macOS default sound for the System notification option (#26321)

On macOS the System sound option passed sound: "default" to Electron, which
the UNUserNotificationCenter backend resolves via soundNamed:"default" (a file
lookup) instead of UNNotificationSound.defaultSound, so banners were silent.
Leave sound unset so Electron plays the OS default, and drop the now-dead
sound field from the notification options type.

Fixes #26043

Co-authored-by: Neil <neil@stably.ai>

* fix(shell): resolve VS Code CLI from standard app installs when `code` is not on PATH (#14010)

GUI-launched Orca often inherits a minimal PATH, so bare `code` fails with
spawn ENOENT for Open in VS Code, including Remote-SSH worktrees. When PATH
and install-dir lookup leave `code`/`code-insiders` unresolved, fall back to
the CLI bundled in the standard macOS app bundle (/Applications or
~/Applications) or the Windows user/system install. Linux packages already
link the CLI onto PATH, so no Linux candidates are added.

Fixes #14008

Co-authored-by: Neil <neil@stably.ai>

* fix(build): keep index.js source map aligned with the bootstrap banner (#33899)

The main bootstrap banner was prepended to index.js in generateBundle,
after Rolldown had already emitted index.js.map, so every main-process
crash trace decoded ~200 lines off. Emit it via Rolldown's output.postBanner
instead, which shifts the source map natively.

Co-authored-by: outof-place <contact@outofplace.space>

* docs: stop promising cross-worktree split panes and Shift-Enter split in Jump Palette (#33902)

Panes are scoped to a single worktree and the Jump Palette has no
Shift-Enter handler, so replace those promises with the real flow:
watch sidebar status indicators and jump between worktrees with
Cmd-J (macOS) / Ctrl-Shift-J (Windows / Linux).

Fixes #10477

* refactor: colocate task page PR presentation rules (#33890)

* fix(drops): keep remote drops out of git and say why drops or file opens fail (#33831)

* fix(drops): keep files dropped onto a remote workspace out of git status

Uploads into `.orca/drops` now write `.orca/drops/.gitignore` (`*`) the first
time it is missing, so dropped files no longer block a plain workspace delete
or get committed by `git add -A`. Callers share one constant for the folder.

Fixes #23856

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Re-check the import session after the drop staging ignore step

The ignore step swallows its own errors, including a stale session. When
nothing was staged the loop never asserted again, so the caller got
skipped/failed results instead of the retry error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(drops): say why a remote drop or file open failed

Co-authored-by: Jae Heo <83424878+jae-heo@users.noreply.github.com>
Co-authored-by: Bing.Z <zzb@gxsmjx.com>

* fix(drops): keep paths out of failure toasts and reuse the too-large classifier

* fix(drops): shorten whole quoted paths in failure toasts

* fix(drops): describe upload failures by category instead of host text

* fix(drops): match failure categories on unquoted text and name manual disconnects

---------

Co-authored-by: Younghwan Chae <chyohw97@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jae Heo <83424878+jae-heo@users.noreply.github.com>
Co-authored-by: Bing.Z <zzb@gxsmjx.com>

* perf(lint): one ast-grep scan feeds both the lint gate and the flaky-test ratchet (#33903)

lint:ast-grep and check-flaky-test-pattern-ratchet each parsed the whole tree.
ast-grep-gates.mjs scans once; the lint gate re-runs ast-grep only on files with
warning-or-error findings (ast-grep's own report and exit code), and the flaky
ratchet counts test-* matches from the shared scan.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(diff): keep the collapsed section header in view (#24466)

Collapsing the section the View all diff viewport was inside kept the
scroll anchor's within-section offset, which pointed into the removed
body, so the structural restore landed on later sections. Pin the anchor
to the section header before the collapse is published.

Fixes #20665

* fix(process-table): pin macOS ps to en_US.UTF-8 so lstart and argv parse under any locale (#33898)

macOS ps formats lstart with the user's locale (%c) and vis-encodes
non-ASCII argv when no UTF-8 locale is set. Under de_DE/en_NZ/ja_JP the
process-table parsers dropped tty/startTime and folded them into command,
breaking agent recognition, pane fingerprints, agent-session start times
and daemon identity. Run those ps readers with LC_ALL=en_US.UTF-8 on
macOS; other platforms keep the inherited env.

Fixes #27004

Co-authored-by: outof-place <contact@outofplace.space>

* fix(editor): match external file changes across Windows path spellings (#14812)

On drive and `\\` UNC roots, an open tab whose filePath used a different
separator or drive-letter spelling than the watcher event (for example
`c:\Repo\File.ts` vs `C:/Repo/File.ts`) never matched, so external edits
did not reload the tab or raise the changed-on-disk banner.

Both direct-match sites (the indexed batch lookup and the non-indexed
fallback in getOpenFilesForExternalFileChange) now compare through
externalChangePathKeyFor(root), which reuses editorBufferPathKey to fold
separators, the drive letter, and the UNC server only. Path-tail case stays
exact, and WSL, POSIX, SSH, and `//` roots keep byte-exact keys.

Fixes #14801

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>

Co-authored-by: Neil <neil@stably.ai>

* fix(memory): show Activity Monitor's footprint in the resource panel on macOS (#33901)

The resource panel summed RSS. On macOS that counts the Electron framework's
shared pages once per helper process, so main and renderer read 2-8x too
high, while the GPU process read 3-5x too low because RSS excludes graphics
memory. The detached terminal daemon was also missing from the app total.

On macOS, sample per-process phys_footprint from top (the figure Activity
Monitor shows) at most every 10 s while the panel is open, and fall back to
RSS when top is unavailable. Count the local terminal daemon in the app's
'other' bucket; its PTY children stay attributed to their worktrees. Linux
and Windows keep RSS/working set.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(wsl-host): keep the bridge port across app restarts so the host isn't re-paired (#33109)

A WSL host's bridge listened on a fresh port each launch, which changed the saved
pairing endpoint. The renderer treats a changed WSL pairing as a different machine
and purges the host's workspaces, tabs and selection, then rebuilds them once the
host relists its projects.

* fix(cli): show orphaned state in human-readable terminal list/show output (#33910)

Fixes #15313

* test(wsl): cover the bash bridge's argv, backpressure, fallbacks and cleanup (#33907)

Prove the relay reaches wsl.exe through --exec unchanged, carries every byte
value both ways, holds the desktop back under a stalled reader on either
side, falls back to Node for no bash, old bash, no /dev/tcp and refused
dials with unchanged sentinels, and leaves no bash or cat behind when
either side goes away.

* fix(termina…
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.

1 participant