Repository navigation
Turn desktop notifications on or off per machine - #24518
Conversation
Settings > Notifications lists each machine (this computer, SSH targets, paired Orca servers) with a switch. Muted machines are stored as an opt-out list so a newly added machine still notifies. Both notification senders now name the machine a workspace runs on, and main skips the desktop banner for a muted one; phone push is unchanged.
|
| state: StoreSnapshot, | ||
| workspaceId: string | ||
| ): { executionHostId?: ExecutionHostId } { | ||
| const executionHostId = getResolvedExecutionHostIdForWorktree(state, workspaceId) |
There was a problem hiding this comment.
VM notifications bypass muting
A VM-backed workspace can resolve to an ssh:runtime-ssh-… ID, while the Machines list hides that SSH target and shows the runtime machine instead. Both notification senders pass the resolved ID to main, which requires an exact match with the muted ID. Turning off the visible runtime switch therefore leaves desktop banners from that workspace enabled.
| description={host.detail} | ||
| checked={!mutedExecutionHostIds.includes(host.id)} | ||
| disabled={disabled} | ||
| onToggle={() => onChange(toggleMutedExecutionHost(mutedExecutionHostIds, host.id))} |
There was a problem hiding this comment.
If someone toggles two machines before the first asynchronous settings write updates the rendered props, both callbacks build their lists from the same old value. The second list replaces the first, so the earlier machine can remain on and keep sending desktop notifications.
| { | ||
| title: translate('auto.components.settings.notifications.search.machines', 'Machines'), |
There was a problem hiding this comment.
Search points to hidden controls
The Machines search result is always available, but the Machines section is hidden when this computer is the only host. In that case, searching for “machines,” “remote,” or “ssh” leads to Notifications without a matching control, making the result misleading.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Important
Host-muting is folded into desktopAllowed, which also gates background push to a paired phone — so the "a muted machine still reaches a paired phone" behavior described in the PR is not what the code does. Please confirm the intended behavior and either preserve phone delivery or correct the description and the test name.
Reviewed changes
- Per-machine mute setting —
mutedExecutionHostIdsadded toNotificationSettings, stored as an opt-out list, normalized on load (junk/dupes dropped, missing → unmuted) and defaulted in the browser client. - Sender plumbing — both notification senders now attach the workspace's owning machine via the new
getNotificationExecutionHostId; main mutes the desktop banner when that id is in the list and reportshost-muted. - Repair comparison —
persistedNotificationSettingsRepairednow compares arrays by content so an unchanged muted list is not rewritten on every launch. - UI + i18n — new
NotificationHostToggles"Machines" section (one switch per host, hidden when only this computer), plus settings-search entry and six locales.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (6)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe change adds per-machine notification muting to notification settings and desktop delivery. Terminal and structured-chat dispatch resolve notification sources from workspace ownership, and terminal events carry PTY and owner metadata. The settings pane displays mute controls for available machines. Settings normalization, delivery behavior, source resolution, and controls have added test coverage, with locale strings for the controls. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to Per-machine notification switches now preserve rapid consecutive changes. The latest changes only add translated text, and no open merge-blocking concern remains. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Machine muting remains separate from execution authority, and the inspected notification paths preserve workspace ownership. The main uncertainty is whether paired phones enforce the intended policy for notifications sent while desktop notifications are disabled. Recovery after failed or interrupted settings saves is also not fully established. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 47 files. (6 skipped: 6 unsupported.)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: fe7768b8-47f9-44ae-baee-04a99a0c7332
📒 Files selected for processing (22)
src/main/notifications/notification-delivery-service.test.tssrc/main/notifications/notification-delivery-service.tssrc/main/persistence/applying-settings/notification-settings-normalization.test.tssrc/main/persistence/applying-settings/onboarding-normalization.tssrc/renderer/src/components/native-chat/structured-attention-dispatch.tssrc/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.tssrc/renderer/src/components/settings/NotificationHostToggles.test.tsxsrc/renderer/src/components/settings/NotificationHostToggles.tsxsrc/renderer/src/components/settings/NotificationsPane.tsxsrc/renderer/src/components/settings/notifications-search.tssrc/renderer/src/components/terminal-pane/terminal-notification-state.test.tssrc/renderer/src/components/terminal-pane/terminal-notification-state.tssrc/renderer/src/components/terminal-pane/use-notification-dispatch.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/renderer/src/web/preload-api/web-preference-normalization.tssrc/shared/notification-settings-defaults.tssrc/shared/notification-settings-types.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.
| description={host.detail} | ||
| checked={!mutedExecutionHostIds.includes(host.id)} | ||
| disabled={disabled} | ||
| onToggle={() => onChange(toggleMutedExecutionHost(mutedExecutionHostIds, host.id))} |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '1,205p' src/renderer/src/components/settings/NotificationsPane.tsx
sed -n '1,100p' src/renderer/src/components/settings/NotificationHostToggles.tsxRepository: stablyai/orca
Length of output: 9540
🏁 Script executed:
#!/bin/bash
set -e
rg -n -S "NotificationsPane|updateSettings=\{|updateSettings:" src/renderer/src | head -120
printf '\n--- notification pane tail ---\n'
sed -n '180,360p' src/renderer/src/components/settings/NotificationsPane.tsx
printf '\n--- candidate settings update definitions ---\n'
rg -n -S "function .*updateSettings|const .*updateSettings|updateSettings\s*=" src/renderer/src src | head -120Repository: stablyai/orca
Length of output: 32776
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- settings slice ---'
sed -n '175,230p' src/renderer/src/store/slices/settings.ts
printf '%s\n' '--- settings renderer matches ---'
rg -n -S -C 8 "NotificationsPane|notifications.*Pane|notificationSettings" src/renderer/src/components/settings src/renderer/src/hooks | head -220
printf '%s\n' '--- notification tests ---'
sed -n '1,180p' src/renderer/src/components/settings/NotificationsPane.test.tsxRepository: stablyai/orca
Length of output: 31128
🏁 Script executed:
#!/bin/bash
set -e
rg -n -S "persistSettingsUpdates" src/renderer/src src/shared
rg -n -S -C 12 "function persistSettingsUpdates|const persistSettingsUpdates|export .*persistSettingsUpdates" src/renderer/src src/sharedRepository: stablyai/orca
Length of output: 2429
🏁 Script executed:
#!/bin/bash
set -e
rg -n -S "persistVisibilityAwareSettings" src/renderer/srcRepository: stablyai/orca
Length of output: 1363
🏁 Script executed:
#!/bin/bash
set -e
sed -n '1,180p' src/renderer/src/store/slices/worktree-visibility-settings-write.tsRepository: stablyai/orca
Length of output: 5411
Preserve rapid changes to different machine switches.
Each NotificationHostToggles callback computes its array from the same mutedExecutionHostIds prop. NotificationsPane merges that array into its optimistic settings, so the second callback can replace the first mute change before refreshed props arrive. Pass the host ID to NotificationsPane, then compute the new array from notificationSettingsRef.current.mutedExecutionHostIds.
Suggested fix
diff --git a/src/renderer/src/components/settings/NotificationHostToggles.tsx b/src/renderer/src/components/settings/NotificationHostToggles.tsx
@@
- onChange: (mutedExecutionHostIds: ExecutionHostId[]) => void
+ onChange: (hostId: ExecutionHostId) => void
@@
- onToggle={() => onChange(toggleMutedExecutionHost(mutedExecutionHostIds, host.id))}
+ onToggle={() => onChange(host.id)}diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx
@@
-import { NotificationHostToggles } from './NotificationHostToggles'
+import {
+ NotificationHostToggles,
+ toggleMutedExecutionHost
+} from './NotificationHostToggles'
@@
- onChange={(mutedExecutionHostIds) =>
- void updateNotificationSettings({ mutedExecutionHostIds })
- }
+ onChange={(hostId) =>
+ void updateNotificationSettings({
+ mutedExecutionHostIds: toggleMutedExecutionHost(
+ notificationSettingsRef.current.mutedExecutionHostIds,
+ hostId
+ )
+ })
+ }| ...updates, | ||
| notifications: { | ||
| // Why: browser-stored settings can predate a newer notification field. | ||
| ...defaults.notifications, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
# Inspect browser preference loading and notification-field validation.
ast-grep outline src/renderer/src/web/preload-api/web-preference-normalization.ts --match mergeSettings --view expanded
rg -n -C 5 'mergeSettings\s*\(|mutedExecutionHostIds|normalizeNotificationSettings' src/renderer/src/web/preload-apiRepository: stablyai/orca
Length of output: 8412
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- normalization ---'
sed -n '100,175p' src/renderer/src/web/preload-api/web-preference-normalization.ts
printf '%s\n' '--- storage loading ---'
sed -n '1,115p' src/renderer/src/web/preload-api/web-preferences-store.ts
printf '%s\n' '--- notification consumers and types ---'
rg -n -C 8 'NotificationHostToggles|mutedExecutionHostIds|interface Notification|type Notification' src/renderer/src | head -240
printf '%s\n' '--- changed diff ---'
git diff --unified=12 8ff6296bc7844fc049357e4e6494aa3b42df8eba 6bc2055c60f76192e4a6610279cab688aaa88ded -- src/renderer/src/web/preload-api/web-preference-normalization.tsRepository: stablyai/orca
Length of output: 30916
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- component location ---'
rg -l --glob '*.ts' --glob '*.tsx' 'export .*NotificationHostToggles|function NotificationHostToggles|const NotificationHostToggles|mutedExecutionHostIds\.includes|mutedExecutionHostIds' src/renderer/src | grep -v '/i18n/locales/' | head -80
printf '%s\n' '--- focused consumer matches ---'
rg -n -C 12 --glob '*.ts' --glob '*.tsx' 'function NotificationHostToggles|const NotificationHostToggles|mutedExecutionHostIds\.includes|mutedExecutionHostIds' src/renderer/src/components src/renderer/src/shared src/renderer/src/web | head -220
printf '%s\n' '--- notification type declarations ---'
rg -n -C 10 --glob '*.ts' --glob '*.d.ts' 'mutedExecutionHostIds' src | grep -v '/i18n/locales/' | head -160Repository: stablyai/orca
Length of output: 31257
Normalize mutedExecutionHostIds before merging browser settings.
getStoredSettings() does not validate this field. When storage contains mutedExecutionHostIds: null, the field overrides the default empty array. NotificationHostToggles can then call .includes on null when host controls are displayed.
Normalize the field to an array of valid host IDs before returning merged settings.
…ifications-per-host
…ifications-per-host
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 3fb2cc4d-29c5-43d3-a4f2-642c0c1cc106
📒 Files selected for processing (40)
src/main/notifications/notification-delivery-service.test.tssrc/main/notifications/notification-delivery-service.tssrc/main/persistence/applying-settings/notification-settings-normalization.test.tssrc/main/persistence/applying-settings/onboarding-normalization.tssrc/renderer/src/attention/notification-sources.test.tssrc/renderer/src/attention/notification-sources.tssrc/renderer/src/attention/notification-subject-owner.tssrc/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsxsrc/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsxsrc/renderer/src/components/native-chat/structured-attention-dispatch.tssrc/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.tssrc/renderer/src/components/settings/NotificationHostToggles.test.tsxsrc/renderer/src/components/settings/NotificationHostToggles.tsxsrc/renderer/src/components/settings/NotificationsPane.test.tsxsrc/renderer/src/components/settings/NotificationsPane.tsxsrc/renderer/src/components/settings/notification-host-pending-save.test.tsxsrc/renderer/src/components/settings/use-notification-source-options.tssrc/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.tssrc/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.tssrc/renderer/src/components/terminal-pane/pty-connection-types.tssrc/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.tssrc/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.tssrc/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.tssrc/renderer/src/components/terminal-pane/pty-transport.tssrc/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.tssrc/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.tssrc/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.tssrc/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.tssrc/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.tssrc/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.tssrc/renderer/src/components/terminal-pane/use-notification-dispatch.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/shared/notification-settings-defaults.tssrc/shared/notification-settings-types.tssrc/shared/notification-source.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- src/renderer/src/i18n/locales/zh.json
- src/renderer/src/i18n/locales/fr.json
- src/renderer/src/i18n/locales/ko.json
- src/renderer/src/i18n/locales/ja.json
- src/renderer/src/i18n/locales/en.json
- src/renderer/src/i18n/locales/es.json
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.
There was a problem hiding this comment.
Important
The structured-chat collision fix only holds while the collided workspace is the active one; switching away flips the remote chat onto the local completion feed. Details inline.
Reviewed changes
- Phone push preserved under a machine mute —
desktopAllowedno longer folds inhostMuted; a muted source skips only the desktop banner after mobile fan-out and its cooldown, and the tests now exercise the realPushDispatcher. - Mutes rebound to configured sources —
mutedExecutionHostIds/executionHostIdbecamemutedNotificationSourceIds/notificationSourceId; the newshared/notification-source.tsmaps an owner to a configured source (unlabelled SSH targets are not sources; recipe VMs and ephemeral cloud VMs collapse tolocal), and Settings builds its rows through the same mapper. - Parked terminals capture their transport owner — parked watchers carry a captured
NotificationWorkspaceOwnerthrough reconciliation so the machine still resolves after a park/reveal cycle. - Structured completions decoupled from owner resolution — the completion feed follows the worktree environment and is passed into the dispatcher for source labelling, with a test for the active collision.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| }) | ||
| store.setState({ | ||
| activeWorktreeId: WORKSPACE, | ||
| activeWorkspaceExecutionHostId: 'runtime:env-1', |
There was a problem hiding this comment.
The workspace is pinned active here (activeWorktreeId: WORKSPACE plus activeWorkspaceExecutionHostId: 'runtime:env-1'), so getRuntimeEnvironmentIdForWorktree short-circuits on the active route and the collision never reaches its ambiguous branch. Switch away from this workspace and the same collision makes that lookup return null, so the bridge subscribes the remote chat to the local completion feed and its remote completions are dropped — the case this commit set out to fix. Widen this test to keep the remote row while the shared workspace is inactive, and derive the feed target from the tab's own host when the worktree lookup is ambiguous.
Technical details
# Collision fix only holds while the collided workspace is active
## Affected sites
- `src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx:19-26` — feed target from `getRuntimeEnvironmentIdForWorktree`; fails closed to `null` on an ambiguous worktree id.
- `src/renderer/src/lib/worktree-runtime-owner.ts:69-80` — `resolveActiveWorkspaceRoute` returns a route only for the active workspace; otherwise `resolveIndexedWorktreeOwner(...).kind === 'ambiguous'` returns `null` (the index marks two rows with different `[repoId, hostId, runtimeOwnerEnvironmentId]` ambiguous at `worktree-runtime-owner-index.ts:177-182`).
- `src/renderer/src/runtime/runtime-client-target.ts:9-10` — `null` becomes `{ kind: 'local' }`.
- `src/renderer/src/runtime/structured-agent-session-turn-completion-feed.ts:39-41,189-199` — the feed is keyed by that target, so the remote host subscription is never opened.
- `StructuredAgentSessionAttentionBridge.test.tsx:405-407` — active route pinned, so the added local sibling only changes `resolveNotificationTabOwner` (asserted `null` at line 473), never the target.
## Required outcome
A remote structured tab on a workspace id that also exists locally keeps its environment feed subscription when the workspace is not the active one. Either derive the target from the tab's stamped host (falling back to the worktree environment) when the worktree lookup is ambiguous, or state that only the active workspace is supported and make the test and claim match.
## Open questions for the human
- Is the non-active collided-worktree case intended to be supported, or is only the active workspace in scope?…ifications-per-host
Review and QA summaryThe problemIf you work across more than one machine (this computer, an SSH machine, or a paired Orca server), every machine's agents send you desktop notifications, and there was no way to quiet just one. A busy QA machine could bury the notifications you care about. What changes for the userSettings → Notifications has a new Machines section, collapsed by default, with an on/off switch for each machine you connected to: this computer, each SSH machine you added, and each paired Orca server, including offline ones.
Fixed during reviewThree review rounds, an architecture challenge, and a readiness check of the final diff. The readiness check raised no release-blocking candidates and no P2s.
How it ended up: each notification takes its machine from the terminal or chat that raised it, never from the selected workspace. One function maps that to a switch, and the Settings list is built from the same set of connected machines. Main applies the mute to the desktop banner only, after the phone step, which is unchanged. The setting is Deferred
VerifiedTests and typechecks: web, node and CLI typechecks plus about 350 targeted tests. CI is green on Live check: a real Electron build of
Machines section, collapsed by default: M4Air mac switched off: the collapsed header shows "1 off": Expanded: one switch per machine, indented under the heading: Settings search for "machines": Master switch off: Directly added SSH machine (throwaway Linux container over SSH):
Not verified
|
There was a problem hiding this comment.
ℹ️ No new issues in this delta. The two new commits are a UI-only refinement of the Machines section; nothing actionable.
Reviewed changes
- Wrapped the Machines rows in an uncontrolled
Collapsiblethat starts collapsed, moving the off-count,Servericon, and description into the trigger, and added collapse/expand plus pending-save coverage. - Replaced the ad-hoc ghost-
Buttontrigger with a newrowCVA variant on the sharedCollapsibleTriggerprimitive, so the disclosure matches the settings rows; existingasChildcallers are unaffected because the empty default variant reduces to the caller's ownclassName.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…ifications-per-host # Conflicts: # src/renderer/src/i18n/locales/en.json # src/renderer/src/i18n/locales/zh.json
* fix: preserve registered account credentials after enrollment errors * refactor(orchestration): one function for six agent-to-agent sends (#24901) * refactor(orchestration): send every agent-to-agent message through one sendAgentTurn The structured mail-pointer lane and the structured worker preamble each carried a copy of "send, wait out a pending start, read the verdict", and four dispatch-preamble sites typed into a terminal directly. They now share sendAgentTurn, built from host.send, the host's settlement waiter and sendTerminalAgentPrompt. Every caller passes delivery 'now', so nothing sends differently; a source-scan ratchet keeps new direct sends out. * test(orchestration): the refusal fixture uses a real wire refusal code * fix(orchestration): derive the send fingerprint inside sendAgentTurn and pair target with turn A `queue` send was refused by a real host: callers supplied a fingerprint over the body alone while the host digests body and delivery. sendAgentTurn now builds the envelope with the composer's own builder (extracted to structured-agent-session-send-mutation), so the fingerprint is always over exactly the fields sent; `now` sends keep the identical digest. The pointer lane no longer carries a fingerprint it cannot get right. sendAgentTurn takes one argument, a union on kind that carries its own target and turn, resolved by an exhaustive switch; the terminal turn names its purpose (only the dispatch preamble today) instead of every terminal send inheriting the task lead line. A queued outcome keeps the host's draft receipt, and both structured callers read outcomes exhaustively with unchanged behaviour. The boundary test now also fences direct structured host sends, catches optional, bracket and bound member uses, has a planted-offender self-test, and pins the unmoved terminal mail pointer and agent-teams tmux senders. * fix(orchestration): keep federation.ts under max-lines and satisfy prefer-template in the send ratchet * fix(orchestration): wait on the answered submission's id when a replayed queue turn was already handed off * test(orchestration): pin that a replayed queue turn waits for its hand-off to settle * test(orchestration): fence call-result and cast host sends, raw terminal writes and the launch-prompt helpers * feat(native-chat): a Claude subagent waiting on a permission prompt reads as waiting (#22634) * feat(native-chat): a Claude subagent waiting on a permission prompt reads as waiting A subagent's permission request reaches the parent session's callback naming the subagent that asked (agent_id) and the tool call it gates (tool_use_id). The pending request is recorded with the asking agent. On every drain the child-work producer re-derives which children a pending request blocks and hands that set to the Claude child decoder, the one owner of each child's live edges: a blocked child reads waiting on every live edge it reports, and a child that starts or stops waiting is a live edge of its own. Answering, denying or cancelling the request returns the child to its prior live state; nothing is stored beyond the pending requests. A live task_updated carrying an error now reaches the record as the child's last message, without an ending or a new state. The replay test drives a scrubbed capture of the real CLI (foreground allow, deny, interrupt, background allow, and the main agent's own request) through the real adapter into the host's child records. * test(native-chat): a subagent's request names it before its tool call is read * docs(agent-status): a subagent asking for approval waits in every lane; the parent row keeps the session's own attention * test(native-chat): hand canUseTool the asking agent without widening the helper's cast * test(native-chat): an interrupted Claude subagent settles cancelled, not failed A captured interrupt shows the spawn call's error result ("The user doesn't want to proceed…") arriving before the subagent's own `task_updated {status: killed}`. The spawn result ends nothing (the child ends only on its own terminal frame), so the child stays live until its `killed` status settles it cancelled. A genuine failure, captured with the subagent on a model that does not exist, sends its `failed` status before the error result and still ends failed. Both captures now replay through the real adapter into the host's records. * fix(native-chat): a Claude subagent's prompt makes the parent row wait, not block A subagent's pending prompt made the whole session `attention`, which reads as the main agent's own `blocked` and outranks the fold's waiting arm, so the parent row read blocked where a CLI Claude parent reads waiting. The main agent's state now reads only its own pending prompts. - A Claude prompt row carries the linkage of the agent that raised it: the one the permission request names, or the owner of the tool call it gates. The same join decides which child reads waiting, so the two cannot disagree. - The status summary projects the session's own status from root prompts only; every other reader (delivery gates, teardown, restart) still asks whether anyone is waiting on a human. - An answer keeps the prompt row's linkage by the journal's own rule: a revision that names no producer keeps the row's existing one. - The child-tool queries gain the prompt's producer, so a prompt row and a child record answer "which agent" from the same join. * test(native-chat): say which ids the permission capture scrubs and which are its own * fix(native-chat): the status clock dates attention by the session's own asks only The session's status is now `attention` only for its own pending prompt, so the clock's fallback to a subagent's ask could no longer be reached, and it read the journal by a different rule than the status it dates. Both now read root prompts. The journal also stamps a Codex subagent's prompt with its thread (#22532), so a Codex child's approval is that child's wait in the Codex lane too. Two tests written for the earlier rule are updated: a subagent's ask leaves a running session `working` on its turn's clock, and a Codex child's answered approval leaves the settled parent's Activity row done with nothing unread. * fix(native-chat): a completion still says the user is asked when a subagent asks The turn-completion feed marked a completion `awaitingUser` from the status summary's `attention`. The status now means the session's own agent is waiting, so a subagent's pending approval stopped reaching the completion. The projection now also says whether anyone is waiting on the user, as the delivery gates, teardown and restart ask it, and the completion reads that. The waiting-subagent replay answers its prompt with the adapter's current response shape. * revert(native-chat): a live Claude task's error stays out of the child's last message No capture shows a live task_updated carrying an error, and it is unrelated to a subagent waiting on a permission request; it leaves this PR. * test(native-chat): settle the Claude session's startup before replaying a subagent's request A startup frame drained child work during the first await, so answering a request freed the child even with the answer's own republish removed. * fix(native-chat): a Claude subagent's prompt row names it as its other rows do The prompt row stamped only the asking agent's id, so a nested subagent's request lost the agent that spawned it, its spawn call and its run. It now takes the linkage the asker's own rows take: the gated tool call's, when that names the same agent, else the one resolved through the agent's spawn call. The provider's agent id stays the asker's id. * fix(native-chat): a subagent's request makes the parent row wait without a child record The parent row learned that a subagent needed the user only from that subagent's child record, so a request no record carried (a Codex child the host never registered, a Claude task past the live cap) left the row working or done while the approval card sat in the chat. "Someone in this session must answer" is now one derived session fact. The projection names two facts instead of a mode flag: the main agent's own status (attention only for its own request) and structuredAgentSessionAwaitsUser (any pending prompt). The status summary publishes the second as an optional awaitsUser, and the shared fold reads it: the main agent's own ask is blocked, otherwise awaitsUser or a waiting child record makes the row wait. Every caller picks the fact it means: the completion edge's awaitingUser and the delivery gate read awaitsUser; the quit snapshot folds the same two inputs the sidebar does. * fix(native-chat): a client that predates awaitsUser still reads a subagent's request as attention A status summary's status is now the main agent's own, so a client built before the split would read a subagent's request as working (or idle) and fold it with code that has no awaitsUser input. Clients advertise agent-session.status-awaits-user.v1; at agentSession.subscribeStatus the host sends any client that does not the pre-split summary: attention whenever awaitsUser is set, without the main agent's own tool line, verdict and clock. The feed and every in-process reader keep the canonical summary. Transitional, like the turn-item downgrade. * test(native-chat): a Codex subagent's approval makes its settled parent's Activity row wait The test pinned the parent row done while a Codex child asked, through a harness that fed no child records, so it proved nothing about the ask. It now drives the ask twice through the real host status store: with no child record (the session's awaitsUser alone) and with the child's own record waiting from thread/status/changed. Both read waiting with needsAttention while the ask is open, then done with nothing unread. * docs(agent-status): a subagent's request reaches the parent row through awaitsUser in every structured lane The store reference said a Codex child's request still read as the main agent's blocked and that only the Codex hook lane fed a waiting child. Both structured lanes stamp the asking child and feed child records, and awaitsUser carries the request when no record does. The liveness comment goes back to main's: a child's blocked is a failed task on an older host's legacy rows. * fix(native-chat): the restart dialog still headlines a subagent's pending approval The quit snapshot now records the main agent's own state, so a subagent asking while the main agent worked recorded `working` and the dialog said "Was mid-reply" where it used to say "Waiting for your approval". The headline now comes from the snapshot's pending prompt, whoever raised it, with the existing copy; `state` stays the main agent's own. * test(orchestration): a subagent's pending approval holds structured mail delivery Scoping the delivery gate to the main agent's own request left every gate test green; a subagent's request now has its own case. * fix(native-chat): a subagent's request is dated by when it was raised, on every client Since the summary's clock became the main agent's own, nothing dated a wait that only a subagent's request held: a pre-split client was sent attention with no clock, where the old host dated it by the subagent's prompt, and a new client's waiting row fell back to the time it first saw it, so after a reload a request the user had already read could read unread again. The session fact is now when someone started being asked: awaitsUserSince, the oldest pending prompt whoever raised it, and its presence is what awaitsUser meant. A row waiting on someone else's request takes that as its clock; the downgrade for a client without the capability dates its attention by it, which is what the old host published. A cross-version test pinned to the last pre-split release runs the same journals through that release's projection and through this one plus the downgrade, and compares the whole summary. The Codex end-to-end test also reads the host's own status row, and keeps a read ask read through a later row and a reload. * test(native-chat): the pre-split parity check compares only the fields the split owns An additive summary field is safe for old clients, so comparing whole summaries against the pinned release would redden on one. The wire comment now says how the downgrade dates attention: the main agent's own oldest ask, else awaitsUserSince. * test(runtime): an aged host-held working summary states that nobody is asked The test built its working summary by overriding the status of a published approval summary, which still carried awaitsUserSince, so the row correctly read waiting. It now drops the request as its scenario says. * fix(native-chat): the chat's subagent block says waiting when the strip does While a Claude subagent's request was open, the sidebar and the composer strip read waiting but the subagent block in the chat history a few pixels above still read "Kicked off 1 subagent working": it shows the journal's roster state, and the journal records no wait. The structured chat now hands its transcript the subagents the strip shows waiting, read from the host's child records through the strip's own row model and matched by the provider id the roster names each one by. A running entry the host says is waiting reads waiting in the group row, its entry and its section head, with the strip's word and the question colour; it reads the journal's state again as soon as the host stops reporting the wait. * fix(native-chat): a collapsed subagent group shows a wait beside a failed sibling A failed sibling took the group row's one alert slot, so a group with a waiting, a working and a failed child read "1 working +1 failed" and hid the wait; it now reads "1 working +1 waiting +1 failed". The waiting set keeps its identity while a child frame changes no wait, so the transcript's subagent rows do not re-render on every frame, and the test of a wait ending now updates one mounted row instead of remounting it. * refactor(claude): one needs-input state on the parent; the asking subagent alone reads waiting Drop the split of the main agent's own status from a session-wide "someone must answer" fact: awaitsUserSince, the agent-session.status-awaits-user.v1 capability and its old-client downgrade, and every reader change that only consumed them (fold, equality, ingest, delivery gate, turn-completion feed, quit snapshot, resume headline, status clock, status bridge, attention dispatch) go back to main. The parent row again reads one needs-input state for a pending request whoever asked, dated as before. Kept: a request's owner recorded once on its prompt row with full producer linkage; the asking subagent's own record reads waiting, re-derived on every update; the chat history's subagent block reads that same state; an answered subagent request stays in its subagent's group. A subagent now waits only on a request the user can still answer (its card open, no answer underway), and the adapter frees it before the host records an answer or dismissal. So a waiting child record always sits beside the pending card, and main's fold never reads the parent as waiting on it: no window after an answer, and no ~3 s wait after a card dismissed by Stop. * fix(claude): a subagent waits only beside its committed card A subagent's wait was pushed to the host as soon as its request arrived, while the request's card row reached the journal at least a microtask later. So every subagent request published the parent row as waiting before blocked (the main agent's own fold reads a waiting child that way), and Activity got an extra unread "waiting" event that main never shows. The card is now the one record of an open request. The translator records the asker on the card once (its row's linkage) and counts the card open only after the sink confirms its rows landed, then publishes the wait; anything that closes the card (an answer underway, a dismissal handed to the host, Claude's own withdrawal, the session's end) frees the subagent first. So every publish that shows a subagent waiting also shows its pending card, and the parent reads one needs-input state, exactly as on main. This retires the registry's view of pending requests (unclaimed(), the asking-child join) and the translator's holdsOpen. The prompt row's linkage takes one rule: the agent the provider names, else the gated call's owner. The parent-row proof now runs through the real deferred sink, durable journal and status feed, publishing as production does, and checks at every publish that waiting subagents have pending cards and that the parent row matches a host fed no waits. * fix(native-chat): a closed sink's dropped writes never read as landed The sink's written() resolved ok when the sink was closed with writes still queued, so a subagent's prompt card could count as open with no row in the journal. written() now reports a close that dropped writes admitted so far as not landed; drained() and lifecycleBarrier() keep reading a closed sink as settled. Tests: a card never opens when its sink closes first; a card Claude withdraws while the sink holds the cancelled row back closes at once; two subagents asking at once, and the main agent asking beside a subagent, keep the parent row as before with each waiting subagent beside its own card; a process that dies mid-request leaves no subagent waiting. * fix(claude): a withdrawn subagent request frees its child before its card closes Main's sink now hands each write to the journal as it is submitted, and an idle journal commits it and runs the publication at once. Claude's own withdrawal of a subagent's request therefore closed the card and published the parent row before the child's wait was freed, so one publish showed the subagent waiting beside no pending card (fg-interrupt replay). The child's wait now also requires the request to still be open in the registry, and a withdrawal republishes child work before the journal takes the close. * refactor(claude): trim subagent request waiting to the common pattern and its essential tests The chat history no longer marks a subagent block as waiting: the approval card itself carries the request, and the asking subagent's row in the sidebar and composer strip reads waiting, as before. NativeChatWaitingSubagentsProvider, native-chat-waiting-subagents.ts and their renderer changes go. A subagent waits while its request is still open and unanswered in the prompt registry and its card has landed in the journal. The registry check also covers a withdrawal under backpressure, so the card list no longer filters pending cancellations itself. Tests: one integration file replays the captured CLI frames through the real adapter, sink, journal and status feed (renamed claude-subagent-permission-request.test.ts), with the asking subagent's state timeline, attribution, nested linkage and a card write that waits for the journal. The producer-harness waiting test, the redundant prompt-card cases, the harness reducer swap and three unused captures (deny, interrupt, main agent, failed subagent) are removed. * test(claude): pin the parent row's dating when a subagent asked first, and narrow the oracle's claim * Turn desktop notifications on or off per machine (#24518) * feat(notifications): turn desktop notifications on or off per machine Settings > Notifications lists each machine (this computer, SSH targets, paired Orca servers) with a switch. Muted machines are stored as an opt-out list so a newly added machine still notifies. Both notification senders now name the machine a workspace runs on, and main skips the desktop banner for a muted one; phone push is unchanged. * fix(notifications): preserve phone alerts and honor machine mutes * fix(notifications): bind machine mutes to configured sources * fix(notifications): preserve chat completion subscriptions on owner collisions * fix(notifications): reduce machine settings clutter with a collapsed section * fix(notifications): align machine disclosure with settings rows * fix(notifications): clarify machine switches affect only this computer * fix: distinguish unavailable account metadata from absence * fix(accounts): resolve registered profile UUIDs consistently * Replace agentArgsOverride with unified removeAgentArgs approach (#25091) Consolidate override detection and removal into removeAgentArgs. Previously catalogs used agentArgsOverride to detect conflicts and removeAgentArgs to strip them; now removeAgentArgs handles both by returning stripped tokens. This eliminates redundancy and makes the intent clearer. Enhance removeAgentArgOption with optional value filtering to support selective removal for complex cases like Codex config overrides. * Reuse the ancestor path while building mobile agent rows (#24539) Preserve traversal order and cycle guards using one call-local path Set rather than a copy at every depth. * Release waiting terminal output when a mobile subscription fails to start (#24547) Dispose the failed subscription record’s existing terminal backlog before rethrowing its original start error. * Avoid cloning terminal agent owners twice in relay listings (#24713) Capture the existing host-age expression at its original time, then use one call-local fresh owner clone for the length check and unchanged published owner field. * Reuse prepared statements for internal worker checks (#24573) * Reuse prepared statements across remaining Dispatch reads Use the existing schema-pinned complete Dispatch column list in five remaining read modules, enabling the existing bounded statement cache without caching results. * Reuse prepared statements for internal worker checks Use the existing complete Dispatch projection only for named-field internal consumers; preserve original wildcard reads for every returned failure snapshot. * Prepare the Activity search query once per filtered list (#24701) Lazily reuse a call-local search predicate inside the existing filter, using unchanged byte gates and thread text cache. * Avoid rescanning shared chunks in the plain Node build guard (#24717) Reuse a successful unchanged chunk-code verdict within one synchronous writeBundle invocation, while preserving traversal and current-code reads. * Select the latest stable release tag in one pass (#24786) Replace filter/sort/last selection with one traversal using the unchanged stable-tag regex and numeric comparator; update on equality to preserve the original last spelling. Both actual callers own private plain arrays from Git stdout. * Avoid Resource Manager renders when terminal removal leaves its inventory unchanged (#24806) Reuse the private React state object only when the existing single/bulk removal helper returns its identical inventory; keep every lifecycle revision, tombstone, known-ID write and helper invocation in its original order. * Cancel pending cursor updates when a terminal closes (#24566) Reuse the existing pane frame tracker to cancel owned deferred focus-class updates during existing cleanup, with disposed guards against late or reentrant delivery. * Cancel terminal preview fit frames when the preview closes (#24712) Reuse the existing frame tracker inside the box-fit owner, suppress scheduling after disposal and cancel pending frames at the start of the existing effect cleanup. * Skip new mobile toast work after feedback owner cleanup (#24759) Reuse the existing mobile mounted-ref lifecycle pattern at toast presentation entry, preserving admitted clipboard outcomes and every live animation/sequence/timer operation. * Release terminal side effects after they have been delivered (#24548) Clear consumed queue slots after successful apply or overflow carry; preserve backing identity across reentrant callbacks and report actual retained slots. * Release relay handshake timers when a host connection leaves (#24554) Reuse the existing first-frame finish pattern to remove stage-owned timer/message/close callbacks on receipt, timeout or close; check OPEN after successful async assignment verification. * Avoid restarting error timers on closed mobile relay links (#24567) Check the existing irreversible closed flag before scheduling the existing missing-close fallback timer. * Avoid restarting error timers after mobile relay pairing closes (#24568) Check the existing pairing owner closed flag before allocating its missing-close fallback alarm. * Delete unreturned clipboard cache files after a failed write (#24599) Reuse existing provider-copy best-effort deletion for a newly created clipboard cache file whose write fails before its URI reaches the caller. * Skip new legacy file inventories after mobile search cleanup (#24792) Reuse the existing mobile mounted-ref pattern only at legacy fallback entry after completed passive cleanup; preserve admitted work and all live search/authority/cache paths. Correct only the strict fully-unmounted inventory scenario and its sole golden. * Discard completed AI Vault cancellation IDs in the relay worker (#24820) Use the relay child's existing pending Set to ignore cancellation IDs after completion, matching its desktop sibling; preserve admitted active/queued cancellation and the bundled private sender's complete replies, errors and ownership. * Release completed updater setup timers and callbacks (#24920) Cancel completed deferred updater fallbacks in finally, clear only their exact pending callback, and drop the captured timer before the original fallback guard runs; preserve destroyed admission, successor ownership and updater/quit callbacks. * Check daemon idle state without building discarded terminal inventories (#24749) * Check daemon idle state without building discarded terminal inventories Replace the private idle predicate full public inventory with a host-local full pass that still reads every Session liveness getter in original order. * Remove overwritten daemon test mock assignment * Preserve the registered account ID when selecting UUID variants * fix(updater): keep macOS Orca open when background instances block updates (#24952) * fix(updater): guard macOS installs against running app instances * fix(updater): match native app blockers and preserve quit lifecycle * fix(updater): keep ordinary macOS quit on Squirrel's install-on-exit path Converting every quit with a staged update into quitAndInstall made Cmd+Q relaunch Orca, refused the quit when background instances existed, and hijacked app.relaunch()+app.quit() restart flows (profile switch, admin restart) into an update install racing the relaunched old app. Only Update & Restart runs the running-instance preflight now; the quit-without-install allowance is no longer reachable and is removed. * fix(updater): preserve quit intent through macOS staging * test(native-chat): explicitly model legacy published tab ownership --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: m4air <m4air@m4airs-Air.localdomain> --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: OrcaWin <alpha-eng@stably.ai> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: m4air <m4air@m4airs-Air.localdomain>







ELI5
If you work across more than one machine (this computer, an SSH machine, or another paired Orca), every one of them sends you desktop notifications, and until now there was no way to quiet just one. A busy QA machine could bury the notifications you actually care about. Settings → Notifications now has a Machines list with an on/off switch for each machine you connected to.
What Changed
Before: Desktop notifications could only be turned off for everything, or per kind (agent finished, terminal bell). A paired Orca running lots of agents sent a banner for every finished turn, with no way to silence only that machine.
After: Settings → Notifications shows a Machines section (collapsed by default; the header shows how many machines are off) with one switch for each thing you connected to: this computer, each SSH machine you added, and each paired Orca server. Turning a switch off stops desktop banners (and their sound) for work on that machine.
Mechanism:
mutedNotificationSourceIds, the machines whose switch is off. It is a list of what is off, so a machine added later notifies by default. Saved settings are validated on load: bad or duplicate ids are dropped, and a missing list means nothing is muted. The "were saved settings repaired on load" check now compares lists by content, so an unchanged list is not rewritten on every launch.notificationSourceId). The sender works out the exact workspace the notification came from using the terminal or chat that raised it: the terminal's connection, a parked terminal's captured owner, or the chat's tab and the server connection the completion arrived on. It never guesses from whichever workspace is selected. One function (src/shared/notification-source.ts) then maps that workspace to a switch: this computer, a directly added SSH machine, or the paired server it was reached through. The Settings list is built from the same set of connected machines, so every notification maps to a switch you can see. If the machine can't be worked out, the notification is never treated as muted.host-muted. It does this after the phone-push step, which runs exactly as before, and before the desktop cooldown, permission check, sound and click handling.Why
This came from noisy notifications from a paired QA machine. The control people want is "not from that machine".
A switch per connected machine is the common pattern for per-server notification controls. One switch covers everything that machine reaches, so the list stays short and every row is something you recognise. The alternative was a row for every physical place work runs, including a server's own SSH machines and short-lived cloud VMs. It would show raw internal ids, leave rows behind after cleanup, and need server-scoped ids. We rejected it for that reason.
The decision is made in main, where the other desktop notification preferences are applied, so both senders (terminals and agent chats) share one rule. The machine is taken from the terminal or chat that raised the notification, not from a workspace-id lookup, because two machines can publish the same workspace id. A lookup could then silence the wrong machine, or none.
Linked Issue
None. Internal request.
Visual Proof
Checked in a real Electron build of this PR connected to three real paired Orca servers. Full results are in the live-check comment below.
Testing
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Delivery: a muted machine gets no desktop banner. Phone push goes through the real push dispatcher and is unchanged, with the same mobile event and cooldown. Other machines and notifications with no known machine still notify. The master switch wins.
Machine mapping: for local git and folder workspaces, direct SSH, paired-server workspaces, a paired server's own SSH machine, and cloud VMs (local and via a paired server), the machine a sender names is one the Settings list shows. Workspace-id collisions between machines (local terminal vs SSH sibling, selected or not; a paired chat vs a local workspace) never pick the wrong machine and never drop a chat's completion subscription. A removed SSH machine's leftover workspaces create no row.
Settings load and save: valid ids kept, junk dropped, unchanged list not counted as a repair. Quick toggles on two machines both persist.
Each regression test was checked to fail against the code before its fix.
Node, web and CLI typechecks, changed-code quality gate, and localization checks pass. New strings are translated in es, fr, ja, ko, zh. Live-checked on macOS in a real Electron build with real paired servers. A real bell and real agent completions from a muted server were held back as host-muted, and other machines still delivered. Not live-checked on Windows or Linux as the client.
AI Disclosure
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)