Skip to content

Add Feed sidebar + cmux feed-hook + OpenCode plugin (workstream MVP) - #3057

Merged
lawrencecchen merged 129 commits into
mainfrom
task-cmux-feed
Apr 26, 2026
Merged

lawrencecchen merged 129 commits into
mainfrom
task-cmux-feed

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a Feed mode to the right sidebar. Three actionable kinds surface inline: permission requests, ExitPlanMode, and AskUserQuestion. Telemetry (tool use, user prompts, assistant messages, TodoWrite) is stored but hidden behind an Actionable / All toggle inside the Feed view.

Works across all 9 supported CLIs via cmux feed-hook --source <agent> (hook-file agents: Claude, Codex, Cursor, Gemini, Copilot, CodeBuddy, Factory, Qoder) plus an OpenCode plugin (Resources/opencode-plugin.js). cmux setup-hooks auto-installs everything, gated on each binary being on PATH.

Internal name is workstream; UI label is Feed.

What's in this PR

Packages/CMUXWorkstream/ — new dual-platform Swift package (.iOS(.v18), .macOS(.v14)). Schema + @MainActor @Observable WorkstreamStore + append-only JSONL persistence actor + recursive AnyJSON for opaque tool_input. 15 unit tests pass.

Six V2 socket verbs on TerminalController: feed.push (blocking when wait_timeout_seconds > 0), feed.permission.reply, feed.question.reply, feed.exit_plan.reply, feed.jump, feed.list. Request-id correlation routes replies back through FeedCoordinator's semaphore map. feed.jump added to the focus-intent allowlist.

FeedCoordinator singleton wired in AppDelegate.applicationDidFinishLaunching. Parks blocking hooks on a DispatchSemaphore, posts UNUserNotificationCenter banners with inline Allow/Deny/Plan-mode buttons when the app isn't key, and routes action responses back via three new categories (CMUXFeedPermission, CMUXFeedExitPlan, CMUXFeedQuestion).

FeedPanelView right-sidebar mode matching the Sessions page aesthetic — 12×12 icons, 13pt titles, rounded hover backgrounds with 6px inset, pill action buttons with destructive-red tinting for Deny, Pending / Resolved / Expired status chips. Pending items float above resolved; Actionable / All segmented filter at top. Double-tap a row → feed.jump focuses the cmux workspace + surface via workspace.select + surface.focus.

Tab badge: red pill with the pending count on the Feed ModeBarButton (red dot + "9+" cap).

cmux feed-hook --source <agent> CLI bridge: reads agent stdin, calls feed.push with 120s blocking wait for actionable events, emits each agent's expected decision JSON on stdout (Claude systemMessage, Codex remember, generic approve/block).

cmux opencode install-hooks [--project] and uninstall-hooks drop ~/.config/opencode/plugins/cmux-feed.js with a // cmux-feed-plugin-marker v1 header for safe upgrades. The plugin connects to $CMUX_SOCKET_PATH, maps OpenCode's event bus, and acknowledges permission decisions via client.session.permissions.

Installer auto-wiring: AgentHookDef grows a feedHookEvents list; each agent gets a PreToolUse (or beforeShellExecution for Cursor) hook invoking cmux feed-hook --source <name> with a 120 000 ms per-event timeout layered alongside the existing notification hooks. Removal is idempotent via a second marker. runSetupHooks runs command -v <agent> and skips agents missing on PATH, printing a summary line.

Persistence: unbounded JSONL at ~/.cmuxterm/workstream.jsonl; memory ring cap is 2000 (decided against per-workstream cap for now). cmux feed clear [--yes] truncates.

Localization: 23 new string keys added to Resources/Localizable.xcstrings with English and Japanese translations.

Docs: new docs/feed.md with architecture diagram, install matrix, decision semantics, storage paths, troubleshooting. docs/notifications.md cross-links Feed.

E2E: new cmuxUITests/FeedSidebarUITests.swift — boots tagged cmux, injects a synthetic permission request via socket, toggles to Feed, clicks Allow Once, asserts the feed.push response returns status: resolved, mode: once.

Deferred (iOS-blocked)

  • Link CMUXWorkstream from the iOS target — waits for the iOS app to land in the repo.
  • iOS WorkstreamTransport over relay WebSocket — waits for terminal-sync.
  • iOS Feed mode + view wiring — waits for the two above.

The packages already declare .iOS(.v18) so the iOS app can link them unchanged once it arrives.

Test plan (dogfood)

  • Package unit tests: cd Packages/CMUXWorkstream && swift test — 15/15 pass.
  • Build: ./scripts/reload.sh --tag cmux-feed succeeds.
  • Install hooks: cmux setup-hooks reports each agent as installed or "skipped (binary not found on PATH)". Verify ~/.codex/hooks.json contains both codex-hook and feed-hook --source codex entries. Check ~/.config/opencode/plugins/cmux-feed.js exists with marker comment.
  • Synthetic Feed push over socket (no agent needed):
    echo '{"id":"1","method":"feed.push","params":{"wait_timeout_seconds":30,"event":{"session_id":"claude-smoke","hook_event_name":"PermissionRequest","_source":"claude","tool_name":"Write","tool_input":{"file_path":"/etc/passwd"},"_opencode_request_id":"smoke-1"}}}' | nc -U /tmp/cmux-debug-cmux-feed.sock
    Verify: row appears pending in Feed with Once/Always/All/Bypass/Deny buttons. Click Allow Once. nc call returns {"ok":true,"result":{"status":"resolved","decision":{"kind":"permission","mode":"once"},"item_id":"…"}}. Row stays in Feed with green "Allowed · once" badge.
  • Native notification: unfocus the cmux window, push a second synthetic frame. macOS banner appears with Allow Once / Always / Deny buttons; clicking Allow in the banner resolves the nc call and updates the Feed row.
  • Badge count: with 2 pending items, verify the Feed sidebar tab shows a red "2" pill. Resolve both, pill disappears.
  • Jump: run Claude Code inside a cmux terminal until cmux-hook session-start fires (sidebar Sessions list will populate). Trigger a permission, double-tap the Feed row. cmux focuses the Claude terminal.
  • Telemetry filter: push a PreToolUse frame with tool_name=Read (no blocking), switch Feed to "All", verify the toolUse row appears only in "All" view.
  • feed clear: cmux feed clear --yes truncates the JSONL. Restart the app — Feed is empty.
  • E2E: gh workflow run test-e2e.yml --ref task-cmux-feed -f test_filter=FeedSidebarUITests. Watch the run; pass means the full click-through is green in CI.
  • Regression: existing cmux setup-hooks notification flow still fires (no double-send, no duplicate notifications).

Files changed

  • Packages/CMUXWorkstream/** (new)
  • Sources/Feed/FeedCoordinator.swift, Sources/Feed/FeedPanelView.swift (new)
  • Sources/TerminalController.swift, Sources/RightSidebarPanelView.swift, Sources/AppDelegate.swift (extended)
  • CLI/cmux.swift (feed-hook bridge, feed-clear, opencode install, feedHookEvents)
  • Resources/opencode-plugin.js, Resources/Localizable.xcstrings (new keys)
  • GhosttyTabs.xcodeproj/project.pbxproj (package + files)
  • docs/feed.md (new), docs/notifications.md (cross-link)
  • cmuxUITests/FeedSidebarUITests.swift (new)

Summary by cubic

Adds a right‑sidebar Feed to review and approve agent decisions, with tap‑to‑jump and an Activity view. Sync cmux feed-hook blocks up to 120s for actionable tools and returns hookSpecificOutput via feed.*, powered by the new CMUXWorkstream store and a bundled OpenCode plugin.

  • New Features

    • Feed with Actionable/Activity filters (Activity = actionable + Todos), flat cards, rich Permission/Plan/Question UIs, inline Stop reply, tap‑to‑jump with terminal flash, and an inline count chip on the tab. Scoped shortcuts: ⌘⇧E focuses the right sidebar; while focused, ⌃1/2/3/4 switch Files/Find/Sessions/Feed. Cmd+F now opens right‑sidebar Find instead of page find.
    • Cross‑CLI bridge via cmux feed-hook --source <agent>; side‑effecting PreToolUse (Bash/Edit/Write/…) blocks up to 120s and returns decisions on stdout (hookSpecificOutput). V2 verbs: feed.push, feed.permission.reply, feed.question.reply, feed.exit_plan.reply, feed.jump, feed.list. Native notifications fire when the app isn’t key. Outside cmux, the hook no‑ops.
    • cmux opencode install-hooks/uninstall-hooks manage the plugin with a colored diff preview, y/N prompt, no‑op detection, and idempotent installs; telemetry is pushed for all hook subcommands. cmux feed clear truncates persistent history.
    • New CMUXWorkstream Swift package (iOS/macOS) with an @Observable store, append‑only JSONL persistence, request‑id correlation for blocking hooks, and a 2k in‑memory ring.
  • Bug Fixes

    • “Submitted” now means the agent received it (sync hook); timeouts fall back to the agent’s TUI and timed‑out prompts auto‑expire.
    • Pending items expire when the agent exits via a kqueue watcher; a startup sweep clears restored stale items.
    • Fixed initial Feed empty state and AskUserQuestion editor/focus behavior; stabilized right‑sidebar focus; guarded native glass controls on unsupported macOS. Claude wrapper exposes bypass availability so the Bypass action works.

Written for commit 451cb58. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Feed right‑sidebar showing agent decision events with actionable responses, filter (Actionable/All), pending badge/count, jump‑to‑session, native notifications, and OpenCode plugin installer
    • CLI: feed management (push/list/clear) plus install/uninstall hook/plugin with interactive ANSI-colored preview
  • Bug Fixes / Behavior

    • Hooks may block up to 120s for user decisions; safe fallback on timeout; feed persisted locally (~/.cmuxterm/workstream.jsonl)
  • Tests

    • Added unit and end‑to‑end UI tests covering feed flows
  • Documentation

    • New Feed guide and updated notifications/setup docs

New Swift package Packages/CMUXWorkstream declares .iOS(.v18) + .macOS(.v14).
Holds the workstream (Feed) data model, an append-only JSONL persistence
actor, and a @mainactor @observable store with request-id correlation for
routing blocking-hook replies. WorkstreamEvent mirrors Vibe Island's wire
field names (session_id, hook_event_name, _source, tool_input, _opencode_request_id)
so existing agent hook payloads flow through unchanged. Actionable kinds
(permissionRequest, exitPlan, question) default to .pending; telemetry kinds
default to .telemetry and are hidden from the Feed by default. 15 tests.
Wires Packages/CMUXWorkstream as an XCLocalSwiftPackageReference on the
GhosttyTabs target, parallel to CMUXAuthCore. Enables importing
`CMUXWorkstream` from Sources/. `xcodebuild -scheme cmux build` succeeds.
Adds six V2 verbs on the cmux socket:
- feed.push           hook/plugin ingest; blocks for user decision when
                      wait_timeout_seconds > 0
- feed.permission.reply, feed.question.reply, feed.exit_plan.reply
                      resolve pending items from the UI
- feed.jump           focus-intent verb (stub resolution lands in the UI PR)
- feed.list           pending/all snapshot for iOS + tests

FeedCoordinator owns the shared @mainactor WorkstreamStore singleton and
mediates between the socket worker thread and the main-actor store. It
also parks blocking hooks on a semaphore keyed by request_id so the
socket reply arrives after the user approves/denies in Feed.

Store is instantiated in AppDelegate.applicationDidFinishLaunching and
immediately starts replay-from-disk.
- `RightSidebarMode.feed` with SF Symbol `dot.radiowaves.left.and.right`
  and localized label "Feed". Persists through the existing
  FileExplorerState.mode → UserDefaults path.
- `FeedPanelView` renders a segmented Actionable / All filter and a
  chronological list where pending items float above resolved. Rows
  receive immutable `FeedItemSnapshot` + closure action bundles, so the
  store is never referenced below the lazy layout boundary.
- Inline rows: PermissionRow (Once/Always/All/Bypass/Deny),
  ExitPlanRow (Bypass/Auto-accept/Manual/Deny),
  QuestionRow (radio or checkbox + Submit),
  TelemetryRow (compact summary for the "All" filter).
New `cmux feed-hook --source <agent>` reads an agent hook JSON payload
from stdin and forwards it to the running cmux app via the `feed.push`
V2 socket verb. For Feed-actionable events (ExitPlanMode,
AskUserQuestion, PermissionRequest) it blocks up to 120s waiting for
the user's decision in the Feed sidebar, then emits the decision JSON
on stdout in each agent's expected format (Claude's
`{"decision": "approve", "systemMessage": "..."}`, Codex's
`{"decision": "approve", "remember": "session"}`, etc.).

Non-actionable events (PreToolUse for ordinary tools, PostToolUse,
Stop, etc.) are pushed as telemetry without blocking — they show up in
the Feed's "All" filter but the agent proceeds immediately. Outside a
cmux terminal (no CMUX_SURFACE_ID) the command silently no-ops so it's
safe to leave installed everywhere.

Installer wiring lands separately; for now users opt in by adding
`cmux feed-hook --source <agent>` as a PreToolUse entry in their
agent's hook config.
Replaces the native macOS Picker + .buttonStyle(.borderless) rows with
the same pill-style buttons and row aesthetic used by SessionIndexView:

- Control bar at 29pt height, horizontal 8 / vertical 3 padding, with
  GroupingButton-equivalent pill selection (icon + 11pt label, rounded
  corner 4, opacity-based hover/selection fills).
- Rows use HStack(spacing: 6) + 12pt title + leading 32 / trailing 12 /
  vertical 4 padding, with the same 6px-inset rounded hover background
  that SessionRow uses. Icons are SF Symbols at 11pt weight medium,
  clamped to 12×12 like the agent asset icons.
- Status tag chips (Pending / Resolved · mode / Expired) render as small
  rounded badges next to the relative timestamp, all in the same
  opacity/tint palette as the sidebar.
- Action chips (Once / Always / All / Bypass / Deny for permissions,
  Bypass / Auto-accept / Manual / Deny for plan mode, Submit for
  questions) reuse the same FeedPillButton primitive, with Deny tinted
  red and hover fills matching the rest of the sidebar.
- Telemetry rows collapse to a single monospaced summary line instead
  of a verbose multi-line block so "All" view stays scannable.

No functional change to the coordinator, socket verbs, or feed-hook —
purely UI polish to stop feeling like a generic system Picker.
- `AgentHookDef` grows a `feedHookEvents` list. For each entry we
  emit a second hook on that agent event invoking
  `cmux feed-hook --source <agent>` with a 120s timeout so user
  decisions don't trip the agent's default (5s–10s) hook timeout.
  Claude's wrapper, Codex, Gemini, Copilot, CodeBuddy, Factory, Qoder
  all get `PreToolUse` wired; Cursor gets `beforeShellExecution`.
- Install/uninstall now scrub both the per-agent hook marker and the
  new `feedHookMarker` so reinstall is idempotent.
- `runSetupHooks` gains a binary-detection gate (`command -v <agent>`)
  and skips agents missing on PATH, printing a summary line.
- New `cmux opencode install-hooks [--project]` / `uninstall-hooks`
  verbs. `setup-hooks` invokes them conditionally when `opencode`
  is on PATH.
- Adds `Resources/opencode-plugin.js` (bundled into cmux.app). The
  plugin connects to `$CMUX_SOCKET_PATH` (fallback
  `~/.config/cmux/cmux.sock`), maps OpenCode's event bus to
  `feed.push` frames, blocks for permission decisions via a 120s
  Promise.race, then acknowledges via `client.session.permissions`.
- Tab badge: red pill showing pending-item count on the Feed
  ModeBarButton in RightSidebarPanelView; feeds off
  `FeedCoordinator.shared.store?.pending.count`.
- Jump: `FeedJumpResolver` parses `<agent>-<sessionId>` workstream ids,
  looks up `~/.cmuxterm/<agent>-hook-sessions.json` to recover
  `(workspaceId, surfaceId)`, posts `.feedRequestFocus`, and AppDelegate
  routes it through the existing workspace.select + surface.focus V2
  verbs via new `TerminalController.handleSocketLine`. Double-tap a
  Feed row to jump.
- Native notifications: when a blocking actionable item arrives and
  the app isn't key, post a `UNUserNotificationCenter` banner with
  inline action buttons on three new categories (CMUXFeedPermission,
  CMUXFeedExitPlan, CMUXFeedQuestion). Action callbacks route back
  to `FeedCoordinator.deliverReply`. First authorization request is
  lazy.
- CLI: `cmux feed clear [--yes]` truncates `~/.cmuxterm/workstream.jsonl`
  for users who want to reset the persistent Feed history.
- Adds 23 Feed-related keys to Resources/Localizable.xcstrings with
  English + Japanese values (rightSidebar.mode.feed, feed.filter.*,
  feed.empty.*, feed.status.*, feed.badge.*, feed.permission.*,
  feed.exitplan.*, feed.question.*).
- docs/feed.md: architecture diagram, install matrix per CLI, decision
  semantics per mode, timeout behavior, storage paths, jump gesture,
  troubleshooting. Linked from docs/notifications.md.
- cmuxUITests/FeedSidebarUITests.swift: boots a tagged cmux with a
  dedicated socket, injects a synthetic PermissionRequest via
  feed.push, toggles the right sidebar to Feed, clicks Allow Once,
  and asserts the socket response carries status=resolved + mode=once.
- Skipping #20 (affordance polish) as a ship-now call; filter chips
  can wait for a concrete user complaint.
@vercel

vercel Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 26, 2026 7:27am

@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Feed subsystem: CLI feed/opencode commands and hook bridge, a new SPM package CMUXWorkstream (models, persistence, store, transport), socket/V2 feed handlers, FeedCoordinator and SwiftUI Feed UI, OpenCode plugin, AppDelegate/TerminalController wiring, tests, docs, and localization.

Changes

Cohort / File(s) Summary
CLI Feed & Hooks
CLI/cmux.swift
Adds cmux feed (clear) and cmux opencode (install-hooks/uninstall-hooks), implements feed-hook dispatch, extends AgentHookDef with feedHookEvents, injects feed-hook bridge entries, and adds diffed ANSI install preview + confirm and careful uninstall pruning.
CMUXWorkstream — Models
Packages/CMUXWorkstream/Sources/CMUXWorkstream/...
WorkstreamAction.swift, WorkstreamEvent.swift, WorkstreamItem.swift, WorkstreamKind.swift, WorkstreamPayload.swift, WorkstreamSource.swift
Adds public domain types for workstream events, payloads, items, statuses, decisions, kinds, sources, and action enums; implements Arbitrary JSON handling and Codable mapping for wire formats.
CMUXWorkstream — Core
.../WorkstreamTransport.swift, WorkstreamPersistence.swift, WorkstreamStore.swift, Package.swift
Introduces WorkstreamTransport/NullWorkstreamTransport, actor-backed append-only JSONL persistence, WorkstreamStore (observable ring buffer with ingest/send/resolve/expire), and package manifest.
CMUXWorkstream — Tests
Packages/CMUXWorkstream/Tests/...
Adds unit tests covering event decoding/roundtrip, item semantics, persistence behavior, and store lifecycle/edge cases (ingest, resolve, eviction, expiration).
Feed Coordinator & UI
Sources/Feed/FeedCoordinator.swift, Sources/Feed/FeedPanelView.swift, Sources/RightSidebarPanelView.swift
Adds FeedCoordinator (blocking ingest with per-request waiters, deliverReply, snapshot, jump resolver, socket encoding) and a SwiftUI right-sidebar Feed panel with actionable/all filters, row snapshots, decision controls, and sidebar badge integration.
Socket & Terminal Integration
Sources/TerminalController.swift
Adds handleSocketLine(_:), registers V2 feed methods (feed.push, reply endpoints, feed.jump, feed.list), decodes/ingests events, and returns blocking/ack payloads for hooks.
AppDelegate & Notifications
Sources/AppDelegate.swift
Installs shared FeedCoordinator store at launch, registers .feedRequestFocus, adds UNNotificationCategory values for feed actions, and maps notification responses into feed replies or focus calls.
OpenCode Plugin (JS)
Resources/opencode-plugin.js
Adds an OpenCode integration plugin CMUXFeed that maintains a persistent cmux socket, sends feed.push frames (telemetry & blocking), matches responses by request_id, and applies decisions into the editor session API.
Localization, Docs & Tests
Resources/Localizable.xcstrings, docs/feed.md, docs/notifications.md, cmuxUITests/FeedSidebarUITests.swift
Adds Feed localization keys, comprehensive docs describing flow/install/semantics, notes in notifications doc, and an end-to-end UI test exercising permission request → user decision → resolved reply over a UNIX socket.
Project Config & Resources
GhosttyTabs.xcodeproj/..., Resources/opencode-plugin.js
Wires new Swift sources, JS resource, UI test into the Xcode project and adds CMUXWorkstream local package dependency.

Sequence Diagram(s)

sequenceDiagram
    participant Agent as Agent (hook)
    participant HookProc as feed-hook
    participant Socket as cmux Socket
    participant TC as TerminalController
    participant FC as FeedCoordinator
    participant Store as WorkstreamStore
    participant UI as FeedPanelView
    participant User as User

    Agent->>HookProc: writes hook JSON on stdin
    HookProc->>Socket: send V2 `feed.push` (event + request_id, wait_timeout)
    Socket->>TC: receive frame -> dispatch handler
    TC->>FC: ingestBlocking(event, waitTimeout)
    FC->>Store: ingest(event)
    Store-->>FC: item created (id, request_id)
    FC->>UI: publish update (pending item)
    User->>UI: select decision
    UI->>FC: deliverReply(requestId, decision)
    FC->>Store: markResolved(itemId, decision)
    FC-->>TC: return resolved payload
    TC-->>HookProc: handler returns decision JSON
    HookProc->>Agent: writes decision JSON to stdout
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibble logs beneath the moon,
A ping, a prompt — the sidebar’s tune.
I tap “Allow”, then give a cheer,
The workstream hops — the answer’s near.
🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main addition: a Feed sidebar plus the supporting feed-hook bridge and OpenCode plugin infrastructure (workstream MVP).
Description check ✅ Passed The PR description comprehensively covers all required sections: Summary explains the Feed feature, What's in this PR details major components, and Testing provides a detailed test plan with specific commands and verification steps.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-cmux-feed

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 and usage tips.

Before: if a user hand-edited hooks.json to add a custom command
inside the same nested group as a cmux hook, install/uninstall kept
the entire group untouched (preserving the user's command but also
leaving the stale cmux hook in place), then appended a fresh cmux
group — leading to duplicate cmux entries on reinstall.

After: we walk each group's `hooks` array and strip only the
cmux-owned entries per-command. Groups whose only content was
cmux-owned drop entirely; groups with any user command survive with
the cmux entries pruned. Uninstall applies the same rule. User
content is never touched.

Also preserves:
- every top-level key in hooks.json / settings.json besides `hooks`
- non-cmux entries under the same agentEvent
- groups with unknown shape (passed through verbatim)
- `codex_hooks` line behavior in ~/.codex/config.toml (only
  that line is touched, rest of the TOML preserved)
- any pre-existing OpenCode plugin file whose header lacks the
  cmux marker (install refuses to overwrite; uninstall skips it)
@greptile-apps

greptile-apps Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds the Feed workstream MVP: a new right-sidebar panel surfacing AI agent permission requests, ExitPlanMode, and AskUserQuestion inline; backed by a new CMUXWorkstream Swift package, six V2 socket verbs, a cmux feed-hook CLI bridge for all nine supported agents, and an OpenCode plugin. The architecture is well-structured and the snapshot-boundary rule is respected in the view layer, but FeedCoordinator.deliverReply contains a deadlock that will fire on every user interaction.

  • deliverReply deadlocks the app on every button click and every notification action. Both FeedRowActions.bound() (SwiftUI button closures wrapped in Task { @MainActor in ... }) and handleFeedNotificationResponse (called on the main queue by UNUserNotificationCenter) call deliverReply from the main thread; deliverReply then calls DispatchQueue.main.sync, which deadlocks. The core Feed interaction — clicking Allow/Deny/etc. — is broken until this is fixed.
  • ingestBlocking uses DispatchQueue.main.sync for the non-blocking telemetry path, blocking the socket worker thread on the main queue for every PreToolUse, PostToolUse, and session lifecycle event, violating the CLAUDE.md socket threading policy.

Confidence Score: 3/5

Not safe to merge — the deadlock in deliverReply makes every Feed button click and notification action hang the app.

Two P1 bugs present: deadlock on all UI action paths and socket threading policy violation in the telemetry hot path. The rest of the PR (store, CLI, plugin, socket verbs, view snapshot boundary) is well-implemented.

Sources/Feed/FeedCoordinator.swift (both P1 bugs), Sources/AppDelegate.swift (notification handler deadlock path)

Important Files Changed

Filename Overview
Sources/Feed/FeedCoordinator.swift New coordinator for workstream state; contains two P1 bugs: deliverReply deadlocks when called from the main thread (via UI buttons and notification actions), and ingestBlocking uses main.sync for the telemetry hot path, violating the socket threading policy.
Sources/Feed/FeedPanelView.swift New Feed sidebar view; respects the snapshot-boundary rule (rows get FeedItemSnapshot + closure bundles only). Minor issues: bare English strings in primaryTitle need localization; workstreamIdForJump switch is a no-op with identical arms.
Sources/AppDelegate.swift Wires FeedCoordinator at launch and registers UNUserNotificationCenter categories; handleFeedNotificationResponse calls deliverReply directly from the main-thread notification delegate, triggering the deadlock identified in FeedCoordinator.
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift New @MainActor @Observable store; clean ring-buffer eviction, request-id index, and persistence integration. No issues found.
Sources/TerminalController.swift Adds six V2 Feed socket verbs; handlers run on background socket threads so the DispatchQueue.main.sync calls inside FeedCoordinator are safe from those callers. feed.jump correctly added to the focus-intent allowlist.
CLI/cmux.swift Adds feed-hook, feed clear, opencode install-hooks/uninstall-hooks subcommands; gracefully degrades outside a cmux terminal with {} fallback. Logic looks correct.
Resources/opencode-plugin.js New OpenCode plugin that bridges events to cmux via socket; uses // cmux-feed-plugin-marker v1 for safe upgrades. Looks well-structured.

Sequence Diagram

sequenceDiagram
    participant Agent as AI Agent Hook
    participant CLI as cmux feed-hook
    participant Socket as Socket Worker Thread
    participant Coord as FeedCoordinator
    participant Store as WorkstreamStore (MainActor)
    participant UI as FeedPanelView (MainActor)
    participant Notif as UNUserNotificationCenter

    Agent->>CLI: stdin hook JSON
    CLI->>Socket: feed.push (wait_timeout=120s)
    Socket->>Coord: ingestBlocking(event, waitTimeout=120)
    Coord->>Store: DispatchQueue.main.sync → store.ingest(event)
    Store-->>UI: @Observable triggers FeedListView re-render
    Coord->>Socket: blocks on DispatchSemaphore

    alt App is not key window
        Coord->>Notif: postFeedNotification(event)
        Notif-->>UI: banner with Allow/Deny actions
    end

    alt User clicks in Feed UI
        UI->>Coord: Task @MainActor → deliverReply(requestId, decision)
        Note over Coord: ⚠️ DispatchQueue.main.sync from main → DEADLOCK
    else User taps notification action
        Notif->>Coord: handleFeedNotificationResponse → deliverReply(...)
        Note over Coord: ⚠️ Called on main thread → DEADLOCK
    else feed.*.reply via socket (safe path)
        Socket->>Coord: deliverReply(requestId, decision)
        Coord->>Store: DispatchQueue.main.sync → markResolved
        Coord->>Socket: semaphore.signal()
        Socket-->>CLI: {status: resolved, decision: {...}}
        CLI-->>Agent: stdout decision JSON
    end
Loading

Reviews (1): Last reviewed commit: "Per-hook filter so user's hooks survive ..." | Re-trigger Greptile

Comment thread Sources/Feed/FeedCoordinator.swift
Comment thread Sources/Feed/FeedCoordinator.swift
Comment thread Sources/Feed/FeedPanelView.swift
Comment thread Sources/Feed/FeedPanelView.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (11)
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift-9-10 (1)

9-10: ⚠️ Potential issue | 🟡 Minor

Either implement extraFieldsJSON or remove this contract.

The docs promise unknown-field preservation, but WorkstreamEvent has no extraFieldsJSON property and CodingKeys drops unknown keys during decode/encode. This makes the forward-compatibility guarantee false.

🛠️ Minimal doc fix if preservation is not implemented yet
-/// `_opencode_request_id`. Unknown fields are preserved verbatim in
-/// `extraFieldsJSON` for forward compatibility.
+/// `_opencode_request_id`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift` around
lines 9 - 10, The docs promise preservation of unknown fields but
WorkstreamEvent lacks an extraFieldsJSON property and its CodingKeys drop
unknown keys; either add an extraFieldsJSON storage and implement custom
init(from:) and encode(to:) on WorkstreamEvent to capture and re-emit unknown
keys (read the keyed container, iterate container.allKeys, decode known keys via
CodingKeys and decode unknown keys into a JSON-compatible representation stored
in extraFieldsJSON) or remove the forward-compatibility statement and any
references to extraFieldsJSON from the documentation and comments; locate the
WorkstreamEvent type and its CodingKeys enum to apply the change.
Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift-153-164 (1)

153-164: ⚠️ Potential issue | 🟡 Minor

Use 64-bit integer extraction for JSON numbers.

NSNumber.intValue truncates when the value exceeds the platform's NSInteger range. While target platforms (iOS 18+, macOS 14+) are 64-bit, where NSInteger and Swift Int are both Int64, explicitly using int64Value is more defensive and clarifies intent. Additionally, add a regression test with a value above Int32.max to catch any future platform changes.

Proposed fix
-            return .int(n.intValue)
+            return .int(Int(n.int64Value))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift` around
lines 153 - 164, In fromAny(_:) replace the current Int extraction that uses
NSNumber.intValue with a 64-bit extraction to avoid truncation: when handling
NSNumber (the branch that currently returns .int(n.intValue)) use n.int64Value
(or convert to Swift Int from n.int64Value) so JSON integers are preserved on
64-bit platforms; update the AnyJSON case or initializer if needed to accept
Int64-backed values and adjust callers accordingly, and add a regression test
that decodes/encodes a numeric value > Int32.max to ensure no
truncation/regression.
Sources/RightSidebarPanelView.swift-135-140 (1)

135-140: ⚠️ Potential issue | 🟡 Minor

Localize the tooltip and use ICU plural forms for the pending count.

helpText returns the literal English "\(count) pending" with no String(localized:) wrapping and no pluralization. This violates the Swift localization guideline and the established .one / .other convention used elsewhere in the app (e.g., statusMenu.unreadCount.one/other). JA/EN callers will all see the English word, and 1 vs n is not handled.

🌐 Proposed fix using a pluralized xcstrings key
     private var helpText: String {
-        if badgeCount > 0 {
-            return "\(mode.label) · \(badgeCount) pending"
-        }
-        return mode.label
+        guard badgeCount > 0 else { return mode.label }
+        let pending = String(
+            localized: "rightSidebar.mode.feed.pendingCount",
+            defaultValue: "\(badgeCount) pending"
+        )
+        return "\(mode.label) · \(pending)"
     }

Add rightSidebar.mode.feed.pendingCount to Resources/Localizable.xcstrings with .one / .other variations for EN and JA (e.g., EN .one: "1 pending", .other: "%lld pending").

As per coding guidelines: "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") and added to Resources/Localizable.xcstrings with translations for all supported languages". Based on learnings, pluralized strings in this repo should use ICU .one / .other keys rather than a single format string.

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

In `@Sources/RightSidebarPanelView.swift` around lines 135 - 140, The helpText
computed property currently returns an English literal for the pending count
(using badgeCount and mode.label) and must be localized with ICU plural forms;
change the branch that returns "\(mode.label) · \(badgeCount) pending" to build
the localized pending-count string via String(localized:
"rightSidebar.mode.feed.pendingCount", defaultValue: "%lld pending") using
badgeCount as the argument (respecting .one/.other variants), then combine it
with mode.label (which remains localized). Also add the pluralized key
rightSidebar.mode.feed.pendingCount to Resources/Localizable.xcstrings with .one
and .other entries for each supported language (follow the
statusMenu.unreadCount.one/other pattern).
Sources/AppDelegate.swift-12672-12712 (1)

12672-12712: ⚠️ Potential issue | 🟡 Minor

Localize the feed notification action titles.

These inline notification button titles are user-facing. Please wrap them with String(localized:defaultValue:) and add EN/JA entries to Resources/Localizable.xcstrings.

Proposed localization update
-                UNNotificationAction(identifier: "feed.permission.once", title: "Allow Once"),
-                UNNotificationAction(identifier: "feed.permission.always", title: "Always"),
+                UNNotificationAction(
+                    identifier: "feed.permission.once",
+                    title: String(localized: "feed.notification.permission.allowOnce", defaultValue: "Allow Once")
+                ),
+                UNNotificationAction(
+                    identifier: "feed.permission.always",
+                    title: String(localized: "feed.notification.permission.always", defaultValue: "Always")
+                ),
                 UNNotificationAction(
-                    identifier: "feed.permission.deny", title: "Deny",
+                    identifier: "feed.permission.deny",
+                    title: String(localized: "feed.notification.permission.deny", defaultValue: "Deny"),
                     options: [.destructive]
                 ),
@@
-                UNNotificationAction(identifier: "feed.exit_plan.bypassPermissions", title: "Bypass"),
-                UNNotificationAction(identifier: "feed.exit_plan.autoAccept", title: "Auto-accept"),
-                UNNotificationAction(identifier: "feed.exit_plan.manual", title: "Manual"),
+                UNNotificationAction(
+                    identifier: "feed.exit_plan.bypassPermissions",
+                    title: String(localized: "feed.notification.exitPlan.bypass", defaultValue: "Bypass")
+                ),
+                UNNotificationAction(
+                    identifier: "feed.exit_plan.autoAccept",
+                    title: String(localized: "feed.notification.exitPlan.autoAccept", defaultValue: "Auto-accept")
+                ),
+                UNNotificationAction(
+                    identifier: "feed.exit_plan.manual",
+                    title: String(localized: "feed.notification.exitPlan.manual", defaultValue: "Manual")
+                ),
@@
                 UNNotificationAction(
-                    identifier: "feed.question.open", title: "Reply",
+                    identifier: "feed.question.open",
+                    title: String(localized: "feed.notification.question.reply", defaultValue: "Reply"),
                     options: [.foreground]
                 ),

As per coding guidelines, “All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") and added to Resources/Localizable.xcstrings with translations for all supported languages”.

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

In `@Sources/AppDelegate.swift` around lines 12672 - 12712, Wrap all user-facing
notification action titles in the UNNotificationCategory declarations with
String(localized:defaultValue:) (e.g., replace the literal "Allow Once",
"Always", "Deny", "Bypass", "Auto-accept", "Manual", "Reply" used in the
UNNotificationAction initializers inside the CMUXFeedPermission,
CMUXFeedExitPlan, and CMUXFeedQuestion category setups) and add corresponding
key entries to Resources/Localizable.xcstrings for EN and JA (use keys matching
the action identifiers like "feed.permission.once", "feed.permission.always",
"feed.permission.deny", "feed.exit_plan.bypassPermissions",
"feed.exit_plan.autoAccept", "feed.exit_plan.manual", "feed.question.open" with
English and Japanese translations).
CLI/cmux.swift-1898-1900 (1)

1898-1900: ⚠️ Potential issue | 🟡 Minor

Register the new commands with pre-socket help.

cmux feed --help, cmux feed clear --help, and cmux opencode --help are intercepted by dispatchSubcommandHelp before these command blocks run, but subcommandUsage(_:) has no feed/opencode cases, so they print “Unknown command”. Add help cases and top-level usage entries instead of relying on the in-block help branch.

🛠️ Proposed direction
 private func subcommandUsage(_ command: String) -> String? {
     switch command {
+    case "feed":
+        return """
+        Usage: cmux feed clear [--yes]
+
+        Manage Feed history.
+        """
+    case "opencode":
+        return """
+        Usage: cmux opencode <install-hooks|uninstall-hooks> [--project]
+
+        Manage the cmux OpenCode Feed plugin.
+        """
     case "ping":
         return """

Also applies to: 1906-1916

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

In `@CLI/cmux.swift` around lines 1898 - 1900, dispatchSubcommandHelp is
intercepting help for "feed" and "opencode" but subcommandUsage(_:) lacks cases
for them, causing "Unknown command" to print; add switch cases for "feed" and
"opencode" (and the nested "clear" help for feed) in subcommandUsage(_:) with
the correct usage strings, and also add top-level usage entries for "feed" and
"opencode" in the function that prints global usage so cmux feed --help, cmux
feed clear --help, and cmux opencode --help show proper pre-socket help instead
of falling through to the in-block help branch.
CLI/cmux.swift-14516-14532 (1)

14516-14532: ⚠️ Potential issue | 🟡 Minor

Count OpenCode plugin operations in the setup summary.

When cmux setup-hooks --agent opencode installs successfully, count remains 0, so the final “Done” line reports 0 installed. Increment count after successful OpenCode install/uninstall.

🛠️ Proposed fix
         if agentFilter == nil || agentFilter?.lowercased() == "opencode" {
             if isUninstall {
                 do { try uninstallOpenCodePlugin() } catch {
                     print("  opencode: \(error.localizedDescription)")
+                    skipped += 1
+                    return
                 }
+                count += 1
             } else if Self.isBinaryOnPath("opencode") {
                 do { try installOpenCodePlugin(projectLocal: false) } catch {
                     print("  opencode: \(error.localizedDescription)")
+                    skipped += 1
+                    return
                 }
+                count += 1
             } else {
                 print("  opencode: skipped (binary not found on PATH)")
                 skippedNoBinary.append("opencode")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 14516 - 14532, The summary count isn't
incremented when OpenCode is successfully installed/uninstalled, so update the
block that handles agentFilter == nil || agentFilter?.lowercased() == "opencode"
to increment the overall count variable after a successful operation: after try
uninstallOpenCodePlugin() completes (no catch) and after try
installOpenCodePlugin(projectLocal: false) completes (no catch). Do not
increment on the skipped branch (where Self.isBinaryOnPath("opencode") is false)
or inside the catch paths; only increment count when the try returns without
throwing, keeping existing updates to skipped and skippedNoBinary intact.
docs/feed.md-13-13 (1)

13-13: ⚠️ Potential issue | 🟡 Minor

Add a language to the fenced diagram block.

markdownlint flags this as MD040. Use text for the ASCII architecture diagram.

Proposed fix
-```
+```text
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/feed.md` at line 13, The fenced code block in docs/feed.md is missing a
language specifier and triggers markdownlint MD040; update the opening fence for
the ASCII architecture diagram by adding the language token "text" (i.e., change
the opening "```" to "```text") so the diagram block is explicitly marked as
text.
docs/feed.md-138-138 (1)

138-138: ⚠️ Potential issue | 🟡 Minor

Don’t say notification banners post without authorization.

postFeedNotification only adds the request when authorization is already granted/provisional or the permission prompt is granted. If notifications are denied, no banner is posted.

Proposed fix
-**Notifications aren't showing inline buttons.** The three Feed categories (`CMUXFeedPermission`, `CMUXFeedExitPlan`, `CMUXFeedQuestion`) are registered at app launch. On first Feed use, macOS may prompt for notification authorization; without it the banner still posts but without action buttons.
+**Notifications aren't showing inline buttons.** The three Feed categories (`CMUXFeedPermission`, `CMUXFeedExitPlan`, `CMUXFeedQuestion`) are registered at app launch. On first Feed use, macOS may prompt for notification authorization; if authorization is denied, Feed rows still appear in the sidebar but no native banner is delivered.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/feed.md` at line 138, Docs incorrectly claim banners post without
authorization; update the text that follows the Feed category registration
(mentions CMUXFeedPermission, CMUXFeedExitPlan, CMUXFeedQuestion) to state that
postFeedNotification only enqueues a UNNotificationRequest when authorization is
already granted or provisional, or when the permission prompt is accepted, and
that if the user denies notifications no banner will be posted; remove or reword
the sentence asserting banners still post without authorization and add a brief
note describing the denied behavior.
docs/feed.md-48-48 (1)

48-48: ⚠️ Potential issue | 🟡 Minor

Correct the scrollback claim for evicted Feed items.

The store keeps only the most recent ring-buffer items in memory; older JSONL entries are disk-only unless a paging/load path exists. “Older items only surface if you scroll back” overpromises current behavior.

Proposed fix
-All events (actionable and telemetry) are appended to `~/.cmuxterm/workstream.jsonl` for audit. Memory holds the most recent 2000 items in a ring; older items only surface if you scroll back.
+All events (actionable and telemetry) are appended to `~/.cmuxterm/workstream.jsonl` for audit. Memory holds the most recent 2000 items in a ring; older items remain available in the JSONL audit log.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/feed.md` at line 48, The doc line overstates scrollback behavior; update
the sentence in docs/feed.md that mentions "~/.cmuxterm/workstream.jsonl" and
the "ring" of 2000 items to clearly state that only the most recent 2000 items
are kept in-memory and older entries reside on disk (workstream.jsonl) and will
not be available unless an explicit paging/load path is implemented; replace
"Older items only surface if you scroll back" with wording that older items are
disk-only unless a paging/load mechanism is provided.
Sources/Feed/FeedPanelView.swift-323-354 (1)

323-354: ⚠️ Potential issue | 🟡 Minor

Localize the remaining Feed row labels and telemetry summaries.

Visible labels like Question, session start, session end, stop, error, ok, and the todos count sentence bypass localization; raw decision mode values also expose wire names like autoAccept.

Suggested direction
-            return "\(snapshot.source.rawValue.capitalized) · Question"
+            return "\(snapshot.source.rawValue.capitalized) · \(String(localized: "feed.kind.question", defaultValue: "Question"))"
...
-        case .sessionStart: return "session start"
-        case .sessionEnd: return "session end"
+        case .sessionStart:
+            return String(localized: "feed.telemetry.sessionStart", defaultValue: "session start")
+        case .sessionEnd:
+            return String(localized: "feed.telemetry.sessionEnd", defaultValue: "session end")

Also use localized display labels for WorkstreamPermissionMode / WorkstreamExitPlanMode, and plural-aware keys for the todos summary. As per coding guidelines, "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") and added to Resources/Localizable.xcstrings with translations for all supported languages".

Also applies to: 552-568

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

In `@Sources/Feed/FeedPanelView.swift` around lines 323 - 354, The Feed row UI
currently uses hard-coded English strings (e.g., "Question", "ExitPlanMode",
timestamp labels in helpText, telemetry summaries like "session start"/"session
end"/"stop"/"error"/"ok", todo counts and raw WorkstreamDecision mode values
such as "autoAccept") — update primaryTitle, helpText, and resolvedBadgeLabel to
use String(localized: "key.name", defaultValue: "English text") for each visible
label; map WorkstreamPermissionMode and WorkstreamExitPlanMode raw values to
localized display labels (use a helper or computed property on those enums),
replace the todos summary with a plural-aware localized key, and ensure any
telemetry summary labels around lines referenced (the other feed row code) are
similarly localized using the same pattern so no user-facing string remains
hard-coded.
cmuxUITests/FeedSidebarUITests.swift-172-175 (1)

172-175: ⚠️ Potential issue | 🟡 Minor

Handle partial socket writes in the UI test helper.

send() is allowed to write fewer bytes than requested. Loop until the whole JSONL frame is sent, otherwise this test can flake by sending a truncated feed.push.

Proposed fix
         let data = line.data(using: .utf8)!
-        _ = data.withUnsafeBytes { bytes in
-            send(sockFd, bytes.baseAddress, data.count, 0)
+        try data.withUnsafeBytes { bytes in
+            guard let base = bytes.baseAddress else { return }
+            var sent = 0
+            while sent < data.count {
+                let n = send(sockFd, base.advanced(by: sent), data.count - sent, 0)
+                if n <= 0 {
+                    throw NSError(
+                        domain: "FeedPush",
+                        code: 3,
+                        userInfo: [NSLocalizedDescriptionKey: "send() failed errno=\(errno)"]
+                    )
+                }
+                sent += n
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/FeedSidebarUITests.swift` around lines 172 - 175, The test helper
currently calls send(sockFd, bytes.baseAddress, data.count, 0) once and assumes
all bytes are written; change this to loop until the entire buffer for `data` is
sent by repeatedly calling `send` (from the block using `data.withUnsafeBytes`)
and advancing the pointer/remaining byte count by the returned `ssize_t`; handle
short writes by retrying, retry on EINTR, and treat negative return values as
errors (failing the test or throwing) so `feed.push` JSONL frames are never
truncated when using `sockFd`, `line`, `data.withUnsafeBytes` and `send`.
🧹 Nitpick comments (2)
Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamStoreTests.swift (1)

42-54: Optional: also assert pending is drained after expiration.

The test verifies the item's status transitions to .expired, but it would be stronger to also assert store.pending.isEmpty so a future regression in the pending filter (e.g., treating .expired as still pending) is caught here. Low priority — current coverage is sufficient for the core behavior.

♻️ Proposed strengthening
         clock.advance(200)
         store.expirePending(olderThan: 60)
+        `#expect`(store.pending.isEmpty)
         if case .expired = store.items[0].status {
             // ok
         } else {
             Issue.record("expected .expired status after timeout")
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamStoreTests.swift`
around lines 42 - 54, Add an assertion after calling
store.expirePending(olderThan:) to ensure the pending collection is drained:
verify store.pending.isEmpty (or equivalent) in the
WorkstreamStoreTests.expirePending test so that when the item's status becomes
.expired the pending queue no longer contains it; this complements the existing
check of store.items[0].status and guards against regressions in
WorkstreamStore.expirePending handling.
CLI/cmux.swift (1)

13863-13868: Remove cmux hooks inside mixed nested groups.

For nested configs, allSatisfy only removes groups where every hook is cmux-owned. If a group contains both a user hook and an older cmux hook, reinstall/uninstall leaves the cmux command behind and can duplicate entries on the next install. Filter the group’s hooks list, then drop the group only if it becomes empty.

♻️ Proposed direction
-                groups.removeAll { group in
-                    guard let hookList = group["hooks"] as? [[String: Any]] else { return false }
-                    return hookList.allSatisfy { isCmuxOwnedCommand($0["command"] as? String ?? "") }
-                }
+                groups = groups.compactMap { group in
+                    guard let hookList = group["hooks"] as? [[String: Any]] else { return group }
+                    var nextGroup = group
+                    let filteredHooks = hookList.filter {
+                        !isCmuxOwnedCommand($0["command"] as? String ?? "")
+                    }
+                    guard !filteredHooks.isEmpty else { return nil }
+                    nextGroup["hooks"] = filteredHooks
+                    return nextGroup
+                }

Also applies to: 13962-13969

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

In `@CLI/cmux.swift` around lines 13863 - 13868, The current logic uses
hookList.allSatisfy to drop a group only when every hook is cmux-owned, leaving
mixed groups with cmux hooks intact; instead, iterate each group in groups and
replace its "hooks" array with a filtered list that removes entries where
isCmuxOwnedCommand((hook["command"] as? String) ?? "") is true, then remove the
group entirely if its filtered "hooks" becomes empty and finally set
hooks[event] = groups.isEmpty ? nil : groups; apply the same change to the other
identical block handling groups/hooks (the code using variables groups, group,
hookList, isCmuxOwnedCommand, and hooks[event]).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 14291-14307: The code sets waitTimeout to 120s for actionable
feed.push but still calls client.send(command:) which uses the default 15s
receive timeout; modify SocketClient.send to accept an optional responseTimeout
parameter and if provided use it for the first read timeout, then update the
call site that builds the payload (where waitTimeout and line are defined) to
call client.send(command: line, responseTimeout: waitTimeout) so actionable
hooks use the 120s socket read window to match the server-side wait.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift`:
- Around line 84-89: WorkstreamEvent currently decodes and stores tool_input
verbatim into toolInputJSON which then gets persisted by WorkstreamPersistence;
add redaction or allowlist filtering before persistence to avoid storing
sensitive shell commands/paths/credentials. Modify the persistence path (e.g.,
WorkstreamPersistence.persist... or the place that consumes
WorkstreamEvent.toolInputJSON) to run a sanitizer that mirrors the allowlist
behavior used by SessionRestoreCommandSettings.isCommandAllowed(_:), and ensure
WorkstreamEvent.toolInputJSON is replaced with the sanitized/filtered string (or
omitted) prior to writing JSONL; alternatively document the retention policy
clearly where WorkstreamPersistence is defined if you choose not to persist raw
tool_input.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift`:
- Around line 71-76: The clear() method is currently swallowing errors by using
try? when removing the file and closing the file handle; change it to propagate
failures instead: replace the try? calls with throwing tries (or capture and
rethrow a combined error) so that removeItem(at: fileURL) and handle.close()
return errors to the caller, ensure handle is still set to nil after
successful/failed close as appropriate, and update the function signature/flow
in WorkstreamPersistence.clear() so consumers (e.g., cmux feed clear) receive a
failure when file removal or handle closing fails.
- Around line 48-66: loadRecent currently reads the entire file into memory
(Data(contentsOf: fileURL)) then applies suffix(limit), which can OOM for an
unbounded audit log and also doesn't guard limit <= 0; change loadRecent to
return early if limit <= 0 and implement tail-reading: open the file
(FileHandle), seek from the end and read fixed-size chunks backwards
accumulating bytes until you have at least limit newline-separated lines (or
reach BOF), then split the accumulated Data by 0x0A, take the last `limit` rows,
decode each with decoder.decode(WorkstreamItem.self, from:), and append to out;
keep the existing behavior of silently dropping malformed lines and use fileURL
and decoder as in the original function.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift`:
- Around line 236-245: The switch in WorkstreamStore.swift is discarding
payloads for .userPromptSubmit, .stop/.subagentStop, and .todoWrite by returning
empty values; update those branches to forward the associated values into the
feed entries so telemetry is preserved: for .userPromptSubmit return
(.userPrompt, .userPrompt(text: <use the associated prompt text>)), for .stop
and .subagentStop return (.stop, .stop(reason: <use the associated stop
reason>)), and for .todoWrite return (.todos, .todos(<use the associated todo
array>)); keep .sessionStart and .sessionEnd as-is. Ensure you reference the
enum cases exactly (.userPromptSubmit, .userPrompt, .stop(reason:),
.subagentStop, .todoWrite, .todos) when locating and changing the switch.

In `@Resources/opencode-plugin.js`:
- Around line 34-38: The resolver currently correlates responses using
msg?.result?.request_id or msg?.request_id, but FeedCoordinator waits on
event.requestId and the permission frame may use _opencode_request_id or the
top-level id; update the correlation to derive requestId from (in order)
msg?.id, msg?._opencode_request_id, or msg?.result?.request_id so
pending.get(requestId) / pending.delete(requestId) / resolver(...) match
FeedCoordinator's event.requestId; apply the same change at both correlation
sites (the block handling pending resolution around the variables requestId,
pending, and resolver).
- Around line 45-58: The write() path currently swallows failures and leaves
pushBlocking() waiters stuck; modify write() and the connection error/close
handling so failures immediately fail any pending blocking waits (e.g., the
pushBlocking pendingPromises/pending map used by pushBlocking) instead of
letting them time out. Specifically: in write(frame) after if (!client) client =
connect(); if client is still null, immediately iterate pending blocking
requests and reject them with a clear error; likewise, catch write errors and in
the catch block reject/fail all pending pushBlocking waiters and clear their
timers; also factor the duplicate logic into a helper (e.g.,
failAllPendingWithError(error)) and call it from conn.on("close"),
conn.on("error"), and write() error paths so pending pushBlocking calls fail
fast when the cmux socket is unavailable.

In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 284-307: The notification construction uses raw, non-localized
strings and includes sensitive data (event.cwd and event.toolInputJSON) that can
leak in system banners; update the switch handling of event.hookEventName to
replace hard-coded titles and bodies with localized values via String(localized:
"key", defaultValue: "...") for each case (e.g., keys for
CMUXFeedPermission.title/body, CMUXFeedExitPlan.title/body,
CMUXFeedQuestion.title/body) and redact or omit sensitive fields (never directly
interpolate event.cwd or event.toolInputJSON into the notification body—replace
with a generic localized placeholder like "Decision needed" or "Review required"
or move sensitive details into the app-only payload). Ensure the code paths that
set content.title, content.body, and content.categoryIdentifier use these
localized and redacted values and add the corresponding keys to
Resources/Localizable.xcstrings.
- Around line 94-108: The synchronous DispatchQueue.main.sync causes deadlock
when deliverReply is called from the main actor; change deliverReply to be async
and replace the DispatchQueue.main.sync { MainActor.assumeIsolated { ... } }
block with await MainActor.run { ... } so the work runs on the main actor
without blocking. Specifically, update the signature of deliverReply to async,
and inside use await MainActor.run { let store = FeedCoordinator.shared.store;
guard let store else { return }; if let itemId = Self.findItemId(for: requestId,
in: store.items) { store.markResolved(itemId, decision: decision) } } ensuring
deliverReply, Self.findItemId, and store.markResolved are called from the main
actor context asynchronously.

In `@Sources/TerminalController.swift`:
- Around line 7123-7124: In v2FeedPush validate the caller-controlled
wait_timeout_seconds instead of defaulting nonnumeric to 0: extract the raw
value from params, ensure it is a numeric Double, isFinite, and within an
allowed window (e.g. >= 0 and <= MAX_WAIT_TIMEOUT), and if not return
V2CallResult.invalid_params with a clear message; if it is valid, use the
clamped/capped timeout value (replace the current waitTimeout assignment).
Ensure the check is done inside the v2FeedPush function and reference the
waitTimeout variable and V2CallResult.invalid_params for the failure path.

---

Minor comments:
In `@CLI/cmux.swift`:
- Around line 1898-1900: dispatchSubcommandHelp is intercepting help for "feed"
and "opencode" but subcommandUsage(_:) lacks cases for them, causing "Unknown
command" to print; add switch cases for "feed" and "opencode" (and the nested
"clear" help for feed) in subcommandUsage(_:) with the correct usage strings,
and also add top-level usage entries for "feed" and "opencode" in the function
that prints global usage so cmux feed --help, cmux feed clear --help, and cmux
opencode --help show proper pre-socket help instead of falling through to the
in-block help branch.
- Around line 14516-14532: The summary count isn't incremented when OpenCode is
successfully installed/uninstalled, so update the block that handles agentFilter
== nil || agentFilter?.lowercased() == "opencode" to increment the overall count
variable after a successful operation: after try uninstallOpenCodePlugin()
completes (no catch) and after try installOpenCodePlugin(projectLocal: false)
completes (no catch). Do not increment on the skipped branch (where
Self.isBinaryOnPath("opencode") is false) or inside the catch paths; only
increment count when the try returns without throwing, keeping existing updates
to skipped and skippedNoBinary intact.

In `@cmuxUITests/FeedSidebarUITests.swift`:
- Around line 172-175: The test helper currently calls send(sockFd,
bytes.baseAddress, data.count, 0) once and assumes all bytes are written; change
this to loop until the entire buffer for `data` is sent by repeatedly calling
`send` (from the block using `data.withUnsafeBytes`) and advancing the
pointer/remaining byte count by the returned `ssize_t`; handle short writes by
retrying, retry on EINTR, and treat negative return values as errors (failing
the test or throwing) so `feed.push` JSONL frames are never truncated when using
`sockFd`, `line`, `data.withUnsafeBytes` and `send`.

In `@docs/feed.md`:
- Line 13: The fenced code block in docs/feed.md is missing a language specifier
and triggers markdownlint MD040; update the opening fence for the ASCII
architecture diagram by adding the language token "text" (i.e., change the
opening "```" to "```text") so the diagram block is explicitly marked as text.
- Line 138: Docs incorrectly claim banners post without authorization; update
the text that follows the Feed category registration (mentions
CMUXFeedPermission, CMUXFeedExitPlan, CMUXFeedQuestion) to state that
postFeedNotification only enqueues a UNNotificationRequest when authorization is
already granted or provisional, or when the permission prompt is accepted, and
that if the user denies notifications no banner will be posted; remove or reword
the sentence asserting banners still post without authorization and add a brief
note describing the denied behavior.
- Line 48: The doc line overstates scrollback behavior; update the sentence in
docs/feed.md that mentions "~/.cmuxterm/workstream.jsonl" and the "ring" of 2000
items to clearly state that only the most recent 2000 items are kept in-memory
and older entries reside on disk (workstream.jsonl) and will not be available
unless an explicit paging/load path is implemented; replace "Older items only
surface if you scroll back" with wording that older items are disk-only unless a
paging/load mechanism is provided.

In `@Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift`:
- Around line 9-10: The docs promise preservation of unknown fields but
WorkstreamEvent lacks an extraFieldsJSON property and its CodingKeys drop
unknown keys; either add an extraFieldsJSON storage and implement custom
init(from:) and encode(to:) on WorkstreamEvent to capture and re-emit unknown
keys (read the keyed container, iterate container.allKeys, decode known keys via
CodingKeys and decode unknown keys into a JSON-compatible representation stored
in extraFieldsJSON) or remove the forward-compatibility statement and any
references to extraFieldsJSON from the documentation and comments; locate the
WorkstreamEvent type and its CodingKeys enum to apply the change.
- Around line 153-164: In fromAny(_:) replace the current Int extraction that
uses NSNumber.intValue with a 64-bit extraction to avoid truncation: when
handling NSNumber (the branch that currently returns .int(n.intValue)) use
n.int64Value (or convert to Swift Int from n.int64Value) so JSON integers are
preserved on 64-bit platforms; update the AnyJSON case or initializer if needed
to accept Int64-backed values and adjust callers accordingly, and add a
regression test that decodes/encodes a numeric value > Int32.max to ensure no
truncation/regression.

In `@Sources/AppDelegate.swift`:
- Around line 12672-12712: Wrap all user-facing notification action titles in
the UNNotificationCategory declarations with String(localized:defaultValue:)
(e.g., replace the literal "Allow Once", "Always", "Deny", "Bypass",
"Auto-accept", "Manual", "Reply" used in the UNNotificationAction initializers
inside the CMUXFeedPermission, CMUXFeedExitPlan, and CMUXFeedQuestion category
setups) and add corresponding key entries to Resources/Localizable.xcstrings for
EN and JA (use keys matching the action identifiers like "feed.permission.once",
"feed.permission.always", "feed.permission.deny",
"feed.exit_plan.bypassPermissions", "feed.exit_plan.autoAccept",
"feed.exit_plan.manual", "feed.question.open" with English and Japanese
translations).

In `@Sources/Feed/FeedPanelView.swift`:
- Around line 323-354: The Feed row UI currently uses hard-coded English strings
(e.g., "Question", "ExitPlanMode", timestamp labels in helpText, telemetry
summaries like "session start"/"session end"/"stop"/"error"/"ok", todo counts
and raw WorkstreamDecision mode values such as "autoAccept") — update
primaryTitle, helpText, and resolvedBadgeLabel to use String(localized:
"key.name", defaultValue: "English text") for each visible label; map
WorkstreamPermissionMode and WorkstreamExitPlanMode raw values to localized
display labels (use a helper or computed property on those enums), replace the
todos summary with a plural-aware localized key, and ensure any telemetry
summary labels around lines referenced (the other feed row code) are similarly
localized using the same pattern so no user-facing string remains hard-coded.

In `@Sources/RightSidebarPanelView.swift`:
- Around line 135-140: The helpText computed property currently returns an
English literal for the pending count (using badgeCount and mode.label) and must
be localized with ICU plural forms; change the branch that returns
"\(mode.label) · \(badgeCount) pending" to build the localized pending-count
string via String(localized: "rightSidebar.mode.feed.pendingCount",
defaultValue: "%lld pending") using badgeCount as the argument (respecting
.one/.other variants), then combine it with mode.label (which remains
localized). Also add the pluralized key rightSidebar.mode.feed.pendingCount to
Resources/Localizable.xcstrings with .one and .other entries for each supported
language (follow the statusMenu.unreadCount.one/other pattern).

---

Nitpick comments:
In `@CLI/cmux.swift`:
- Around line 13863-13868: The current logic uses hookList.allSatisfy to drop a
group only when every hook is cmux-owned, leaving mixed groups with cmux hooks
intact; instead, iterate each group in groups and replace its "hooks" array with
a filtered list that removes entries where isCmuxOwnedCommand((hook["command"]
as? String) ?? "") is true, then remove the group entirely if its filtered
"hooks" becomes empty and finally set hooks[event] = groups.isEmpty ? nil :
groups; apply the same change to the other identical block handling groups/hooks
(the code using variables groups, group, hookList, isCmuxOwnedCommand, and
hooks[event]).

In
`@Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamStoreTests.swift`:
- Around line 42-54: Add an assertion after calling
store.expirePending(olderThan:) to ensure the pending collection is drained:
verify store.pending.isEmpty (or equivalent) in the
WorkstreamStoreTests.expirePending test so that when the item's status becomes
.expired the pending queue no longer contains it; this complements the existing
check of store.items[0].status and guards against regressions in
WorkstreamStore.expirePending handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb1a25f5-aa62-4fd6-a303-c5b87dba2117

📥 Commits

Reviewing files that changed from the base of the PR and between 0bdf694 and 75d31ce.

📒 Files selected for processing (26)
  • CLI/cmux.swift
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Packages/CMUXWorkstream/Package.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAction.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamKind.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPayload.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamSource.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift
  • Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamTransport.swift
  • Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamEventTests.swift
  • Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamItemTests.swift
  • Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamPersistenceTests.swift
  • Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamStoreTests.swift
  • Resources/Localizable.xcstrings
  • Resources/opencode-plugin.js
  • Sources/AppDelegate.swift
  • Sources/Feed/FeedCoordinator.swift
  • Sources/Feed/FeedPanelView.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/TerminalController.swift
  • cmuxUITests/FeedSidebarUITests.swift
  • docs/feed.md
  • docs/notifications.md

Comment thread CLI/cmux.swift
Comment thread Resources/opencode-plugin.js Outdated
Comment thread Resources/opencode-plugin.js Outdated
Comment thread Sources/Feed/FeedCoordinator.swift
Comment thread Sources/Feed/FeedCoordinator.swift
Comment thread Sources/TerminalController.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (1)
CLI/cmux.swift (1)

14313-14328: ⚠️ Potential issue | 🟠 Major

Match the socket read timeout to the Feed decision wait.

Actionable Feed hooks ask the server to wait up to 120s, but client.send(command:) still uses the default 15s receive timeout, so the hook can fall back to {} while the server is still waiting.

🐛 Proposed direction
-            response = try client.send(command: line)
+            response = try client.send(
+                command: line,
+                responseTimeout: waitTimeout > 0 ? waitTimeout + 5 : nil
+            )

Add the optional responseTimeout parameter to SocketClient.send and use it for the first response read timeout.

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

In `@CLI/cmux.swift` around lines 14313 - 14328, The feed.push call sets
wait_timeout_seconds to 120 for actionable hooks but the socket read still uses
the SocketClient.send default 15s timeout, causing premature {} responses;
update the SocketClient.send API to accept an optional responseTimeout parameter
and wire it through so the first read uses that timeout, then call
client.send(command: line, responseTimeout: waitTimeout) (or convert waitTimeout
to seconds Double) where feed.push is sent; ensure the new parameter is used
only for the initial response-read and preserve existing default behavior when
nil.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 14430-14438: The Codex branch currently converts mode "always" to
remember "session", losing persistent approval; update the mapping in the block
handling source == "codex" so that when mode is "always" or "bypass" the
out["remember"] value is set to "always", otherwise set it to "session"
(preserve the existing removal of "systemMessage" and assignment to
out["remember"]). Locate the conditional that checks mode and source (variables
mode, source and dictionary out) and replace the existing ternary logic (which
only maps "bypass" to "always") with logic that treats "always" and "bypass" as
persistent approvals.
- Around line 14298-14303: The "_ppid" value in the eventDict currently uses
ProcessInfo.processInfo.processIdentifier (the current process PID); change it
to send the parent PID instead by calling getppid() and converting to an Int
(i.e., set "_ppid" to Int(getppid())). Update the eventDict definition (the
dictionary built where session_id, hook_event_name, _source, and _ppid are set)
so that the "_ppid" entry uses getppid() rather than
ProcessInfo.processInfo.processIdentifier.
- Around line 14538-14557: The OpenCode branch fails to update the setup totals
because it never increments the shared counter; inside the block that handles
OpenCode (the if checking agentFilter and Self.isBinaryOnPath("opencode")),
increment the same `count` variable when installOpenCodePlugin(projectLocal:)
succeeds and when uninstallOpenCodePlugin() succeeds (i.e., after the try
completes), and ensure skipped/skippedNoBinary logic remains unchanged so the
final print("Done: \(count) ...") reflects OpenCode operations correctly.
- Around line 13781-13783: feedHookCommand currently builds a cmux feed-hook
invocation without passing the hook's configured event, so the feed
defaults/misclassifies to PreToolUse; update the command string in
feedHookCommand to include the configured event by adding the --event argument
with the hook def's agentEvent (e.g. append `--event \(def.agentEvent)` properly
quoted) so the installed hook forwards the correct event name; apply the same
change to the other similar invocations referenced (the blocks at the other
locations) that build cmux feed-hook commands.
- Around line 2818-2827: runFeedHook currently checks the CMUX_SURFACE_ID guard
only after calling client.connect()/authentication which causes external hooks
to fail with socket errors; modify runFeedHook so the CMUX_SURFACE_ID presence
check happens immediately at the start (before any client.connect() or auth
calls) and short-circuit by returning the same empty hook response (i.e. `{}`
behavior used by other hook bridges) and recording the appropriate telemetry
breadcrumb (e.g., "feed-hook.no-surface" or reuse existing breadcrumb flow)
instead of opening the socket; reference the runFeedHook function,
CMUX_SURFACE_ID guard, and client.connect()/authentication call sites to locate
and update the logic.

---

Duplicate comments:
In `@CLI/cmux.swift`:
- Around line 14313-14328: The feed.push call sets wait_timeout_seconds to 120
for actionable hooks but the socket read still uses the SocketClient.send
default 15s timeout, causing premature {} responses; update the
SocketClient.send API to accept an optional responseTimeout parameter and wire
it through so the first read uses that timeout, then call client.send(command:
line, responseTimeout: waitTimeout) (or convert waitTimeout to seconds Double)
where feed.push is sent; ensure the new parameter is used only for the initial
response-read and preserve existing default behavior when nil.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72540e67-3b6c-47a5-9fbc-3b037de85e00

📥 Commits

Reviewing files that changed from the base of the PR and between 75d31ce and f764ec0.

📒 Files selected for processing (1)
  • CLI/cmux.swift

Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift
`cmux setup-hooks` (and all per-agent install-hooks paths + OpenCode
plugin + codex config.toml) now prints a colored unified diff instead
of dumping the full new content. Output is framed with the target
path both above and below the diff so the user always knows which
file is about to be written regardless of scrollback position:

  ─── Will write to /Users/…/.codex/hooks.json ───
  --- old
  +++ new
  @@ -42,0 +43,7 @@
  +            {
  +              "command": "[ -n \"$CMUX_SURFACE_ID\" ] && … cmux feed-hook --source codex …",
  +              "timeout": 120000,
  +              "type": "command"
  +            }
  ─── The above will be written to /Users/…/.codex/hooks.json ───

Colors (only when stdout is a tty, via isatty check):
- +/- lines green/red
- @@ hunk headers cyan
- file headers dim
- JSON strings cyan, numbers yellow, booleans/null magenta for
  fallback when diff isn't available (new file, diff missing)

Shells out to /usr/bin/diff -u against temp files; gracefully falls
back to the full pretty-printed content if diff isn't on the system.
Applies to agent hook files, codex config.toml, and the OpenCode
plugin JS.
- Bold the +/- lines in addition to coloring them, so additions and
  deletions stand out even when the diff scrolls past quickly.
- Prepend a summary line "+N additions, -M deletions" so users see
  the shape of the change before scanning for specific entries.
- Swap the "Will write to" header to "Will create" when the target
  file doesn't exist, so the banner matches reality.
- Handle the no-op case explicitly with "(no changes — file already
  matches target)" instead of dumping full content.
- When diff isn't available (binary missing, temp write failure),
  prefix every fallback line with a bold green `+` so the user still
  sees which content is landing.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

13 issues found across 26 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift">

<violation number="1" location="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift:52">
P2: `loadRecent(limit:)` loads the full JSONL file into memory, which does not scale with the unbounded log design.</violation>

<violation number="2" location="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift:72">
P2: `clear()` swallows filesystem errors with `try?`, so callers can get a false success when the file was not actually cleared.</violation>
</file>

<file name="Sources/RightSidebarPanelView.swift">

<violation number="1" location="Sources/RightSidebarPanelView.swift:137">
P3: Localize the `helpText` pending tooltip instead of hard-coding the English word "pending".</violation>
</file>

<file name="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift">

<violation number="1" location="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift:62">
P2: Validate or normalize explicit `status` in the initializer; as written, non-actionable items can be initialized with actionable statuses.</violation>
</file>

<file name="Resources/opencode-plugin.js">

<violation number="1" location="Resources/opencode-plugin.js:34">
P1: Response correlation ignores V2 response `id`, so resolved `feed.push` replies are not matched to pending requests.</violation>

<violation number="2" location="Resources/opencode-plugin.js:45">
P2: Socket close/error handlers do not clear buffered state or pending waiters, causing unnecessary 120s stalls after disconnects.</violation>
</file>

<file name="cmuxUITests/FeedSidebarUITests.swift">

<violation number="1" location="cmuxUITests/FeedSidebarUITests.swift:154">
P2: Replace assertion-based error handling with a thrown error in this throwing socket helper so failures abort immediately.

(Based on your team's feedback about throwing timeout/errors in async test helpers.) [FEEDBACK_USED]</violation>
</file>

<file name="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift">

<violation number="1" location="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift:62">
P2: Unknown event fields are dropped instead of preserved, breaking the forward-compatibility contract described for this type.</violation>

<violation number="2" location="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift:105">
P2: `tool_input` is silently dropped when `toolInputJSON` is non-JSON text; encode should fall back to encoding the raw string.</violation>
</file>

<file name="Sources/Feed/FeedCoordinator.swift">

<violation number="1" location="Sources/Feed/FeedCoordinator.swift:56">
P1: `deliverReply` can deadlock because it always calls `DispatchQueue.main.sync`, even when already running on the main actor.</violation>
</file>

<file name="Sources/AppDelegate.swift">

<violation number="1" location="Sources/AppDelegate.swift:7595">
P1: Feed focus routing uses wrong V2 parameter names (`workspace`/`surface`), so `workspace.select` and `surface.focus` reject the request and focus does not occur.</violation>
</file>

<file name="Sources/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:2160">
P2: Add the new `feed.*` RPC methods to `system.capabilities`; otherwise capability-driven clients won’t discover supported feed commands.</violation>
</file>

<file name="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift">

<violation number="1" location="Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift:38">
P2: Remove the unused request-id mapping dictionaries (`requestIdToItemId` and `itemIdToRequestId`) and their associated lifecycle code.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Resources/opencode-plugin.js Outdated
Comment thread Sources/Feed/FeedCoordinator.swift
Comment thread Sources/AppDelegate.swift Outdated
Comment thread Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift Outdated
Comment thread Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamPersistence.swift Outdated
Comment thread Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift Outdated
Comment thread Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamEvent.swift Outdated
Comment thread Sources/TerminalController.swift
Comment thread Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift Outdated
Comment thread Sources/RightSidebarPanelView.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
CLI/cmux.swift (3)

14495-14510: ⚠️ Potential issue | 🟠 Major

Match the socket timeout to the 120-second Feed wait.

feed.push is asked to wait up to 120 seconds, but client.send(command:) still uses the CLI default receive timeout of 15 seconds. Any user decision after ~15 seconds is treated as a socket failure and degrades to {}.

🐛 Proposed direction
-            response = try client.send(command: line)
+            response = try client.send(
+                command: line,
+                responseTimeout: isActionable ? waitTimeout + 5 : nil
+            )

This also needs a SocketClient.send(command:responseTimeout:) overload or equivalent per-call receive timeout path.

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

In `@CLI/cmux.swift` around lines 14495 - 14510, The socket receive timeout for
feed.push must match the wait timeout (isActionable ? 120 : 0); add a per-call
receive-timeout path to the SocketClient API (e.g., add or expose
SocketClient.send(command:responseTimeout:) or similar) and change the call site
that currently invokes client.send(command: line) inside the do block to use the
new overload and pass the same waitTimeout (or a nonzero mapped value when
waitTimeout>0) so the socket will wait up to 120 seconds for the feed.push
response; update any related method signatures (SocketClient.send) and callers
to support this per-call responseTimeout without altering global defaults.

2818-2827: ⚠️ Potential issue | 🟠 Major

No-op feed-hook before opening the socket.

The graceful outside-cmux guard inside runFeedHook is reached only after client.connect() and authentication. If an installed hook fires outside cmux with no socket, this path errors instead of printing {} like the other hook handlers.

🐛 Proposed fix
         if ["copilot-hook", "codebuddy-hook", "factory-hook", "qoder-hook"].contains(command) {
             guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else {
                 print("{}")
                 return
             }
         }
+
+        if command == "feed-hook" {
+            guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]?.isEmpty == false else {
+                print("{}")
+                return
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 2818 - 2827, The feed-hook path calls
runFeedHook only after attempting client.connect()/auth, so when a hook fires
outside cmux (no socket) it errors instead of returning the empty JSON; modify
the "feed-hook" dispatch to detect the missing socket before calling runFeedHook
(or move the graceful outside-cmux guard from inside runFeedHook to run before
client.connect()), and when no socket is present print "{}" and return
successfully; reference the existing case "feed-hook" branch and the runFeedHook
function and the client.connect() call so the check is applied prior to
connection/auth.

14720-14739: ⚠️ Potential issue | 🟡 Minor

Include OpenCode in the setup-hooks summary count.

OpenCode install/uninstall runs outside the agentDefs loop, but count is never incremented. cmux setup-hooks --agent opencode can successfully install the plugin while reporting Done: 0 installed.

🐛 Proposed fix
             if isUninstall {
                 do { try uninstallOpenCodePlugin() } catch {
                     print("  opencode: \(error.localizedDescription)")
                 }
             } else if Self.isBinaryOnPath("opencode") {
                 do { try installOpenCodePlugin(projectLocal: false) } catch {
                     print("  opencode: \(error.localizedDescription)")
                 }
+                count += 1
             } else {

Also increment after a successful uninstall if you want uninstall summaries to include OpenCode.

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

In `@CLI/cmux.swift` around lines 14720 - 14739, The OpenCode branch does not
update the overall summary `count`, so successful installs/uninstalls report as
zero; inside the block guarded by `if agentFilter == nil ||
agentFilter?.lowercased() == "opencode"` increment `count` when
`installOpenCodePlugin(projectLocal: false)` or `uninstallOpenCodePlugin()`
completes successfully (and also increment for successful uninstall when
`isUninstall` is true), leaving the existing error handling and the
`skipped`/`skippedNoBinary` updates intact; reference `agentFilter`,
`isUninstall`, `uninstallOpenCodePlugin()`,
`installOpenCodePlugin(projectLocal:)`, `Self.isBinaryOnPath("opencode")`,
`count`, `skipped`, and `skippedNoBinary` when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 13698-13700: classifyFeedEvent currently lacks a branch for the
installed feed hook "beforeShellExecution", so those events fall through to
("PreToolUse", false) and cannot return a user decision; update the classifier
(function/classifyFeedEvent) to explicitly handle the "beforeShellExecution"
event and return the correct mapping (e.g., the intended event name and whether
it requires user decision) consistent with other feedHookEvents entries, or
alternatively remove "beforeShellExecution" from the feedHookEvents list if it
should be telemetry-only; reference the feedHookEvents array and the
classifyFeedEvent switch/lookup to add or remove the mapping accordingly.
- Around line 1891-1916: The generic help gate is catching "cmux feed --help"
and "cmux opencode --help" before the feed/opencode branches run because
subcommandUsage(_:) has no entries for those commands; update
subcommandUsage(_:) to include usage text for "feed" (e.g., "feed clear
[--yes]") and "opencode" (e.g., "opencode install-hooks|uninstall-hooks") or
modify the pre-socket help gate to bypass help handling when command == "feed"
or command == "opencode" so the existing handlers (runFeedClear,
runOpenCodeInstallHooks, runOpenCodeUninstallHooks) can execute and present the
intended help text.

---

Duplicate comments:
In `@CLI/cmux.swift`:
- Around line 14495-14510: The socket receive timeout for feed.push must match
the wait timeout (isActionable ? 120 : 0); add a per-call receive-timeout path
to the SocketClient API (e.g., add or expose
SocketClient.send(command:responseTimeout:) or similar) and change the call site
that currently invokes client.send(command: line) inside the do block to use the
new overload and pass the same waitTimeout (or a nonzero mapped value when
waitTimeout>0) so the socket will wait up to 120 seconds for the feed.push
response; update any related method signatures (SocketClient.send) and callers
to support this per-call responseTimeout without altering global defaults.
- Around line 2818-2827: The feed-hook path calls runFeedHook only after
attempting client.connect()/auth, so when a hook fires outside cmux (no socket)
it errors instead of returning the empty JSON; modify the "feed-hook" dispatch
to detect the missing socket before calling runFeedHook (or move the graceful
outside-cmux guard from inside runFeedHook to run before client.connect()), and
when no socket is present print "{}" and return successfully; reference the
existing case "feed-hook" branch and the runFeedHook function and the
client.connect() call so the check is applied prior to connection/auth.
- Around line 14720-14739: The OpenCode branch does not update the overall
summary `count`, so successful installs/uninstalls report as zero; inside the
block guarded by `if agentFilter == nil || agentFilter?.lowercased() ==
"opencode"` increment `count` when `installOpenCodePlugin(projectLocal: false)`
or `uninstallOpenCodePlugin()` completes successfully (and also increment for
successful uninstall when `isUninstall` is true), leaving the existing error
handling and the `skipped`/`skippedNoBinary` updates intact; reference
`agentFilter`, `isUninstall`, `uninstallOpenCodePlugin()`,
`installOpenCodePlugin(projectLocal:)`, `Self.isBinaryOnPath("opencode")`,
`count`, `skipped`, and `skippedNoBinary` when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05abc0f9-3931-49e9-8a72-3a8aea8fe7a1

📥 Commits

Reviewing files that changed from the base of the PR and between f764ec0 and 7c321ea.

📒 Files selected for processing (1)
  • CLI/cmux.swift

Comment thread CLI/cmux.swift
- installAgentHooks short-circuits when pretty-printed old content
  already matches new. Prints "<Agent> hooks already up to date at
  <path>" and moves on without prompting or writing.
- FeedPillButton backgroundFill now matches SessionIndexView's
  GroupingButton: clear when unselected-and-unhovered, 0.05 hover,
  0.10 selected. Fixes the always-visible bg on unselected segments
  of the Actionable / All filter.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (1)
Sources/Feed/FeedPanelView.swift (1)

161-163: Drop the redundant jump indirection.

FeedItemSnapshot already carries workstreamId, and both switch branches return the same value. Removing the helper also avoids the misleading “snapshot doesn't carry it” comment.

♻️ Proposed cleanup
-private func snapshotWorkstreamId(_ s: FeedItemSnapshot) -> String {
-    s.workstreamId
-}
-
@@
         .help(helpText)
         .onTapGesture(count: 2) {
-            actions.jump(workstreamIdForJump)
+            actions.jump(snapshot.workstreamId)
         }
     }
 
-    private var workstreamIdForJump: String {
-        // Store mirror of the workstream id; snapshot doesn't carry it
-        // directly because payloads do. Fall back to source-only when
-        // the item kind doesn't embed a session linkage.
-        switch snapshot.payload {
-        case .permissionRequest, .exitPlan, .question:
-            return snapshotWorkstreamId(snapshot)
-        default:
-            return snapshotWorkstreamId(snapshot)
-        }
-    }
-

Also applies to: 245-260

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

In `@Sources/Feed/FeedPanelView.swift` around lines 161 - 163, Remove the
redundant helper snapshotWorkstreamId and inline FeedItemSnapshot.workstreamId
wherever it’s used: delete the snapshotWorkstreamId(_ s: FeedItemSnapshot)
function, replace its call sites with s.workstreamId, and remove any comments
suggesting the snapshot lacks workstreamId; do the same cleanup for the
duplicated helper variant used in the other location (the second helper around
lines 245–260) so all callers use FeedItemSnapshot.workstreamId directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 13965-13973: The preview is shown but never prompts the user to
confirm before writing; update the flow around Self.printInstallPreview so that
after printing the preview (when skipConfirm is false) you explicitly solicit a
yes/no confirmation (e.g., via an existing confirm helper or a readLine-based
prompt) and only proceed to call try newContent.write(toFile: configPath, ...)
if the user answered yes; if the user declines, skip the write and keep
existingContent. Apply the same change to the analogous block referenced around
lines 14420-14430 so both Codex `config.toml` post-install and OpenCode plugin
install honor the confirmation contract (use symbols skipConfirm,
Self.printInstallPreview, configPath, existingContent, newContent, and the write
call to locate the sites to change).
- Around line 14763-14769: The setup output currently prints
error.localizedDescription which hides CLIError.description; update the error
handling in the uninstallOpenCodePlugin() and
installOpenCodePlugin(projectLocal:) call sites so that when catching an error
you check if it is a CLIError (e.g., if let cliErr = error as? CLIError) and
print cliErr.description, otherwise print error.localizedDescription — keep the
same "  opencode: ..." prefix to preserve format.
- Around line 14536-14551: The feed push is asking the server to wait up to
waitTimeout (isActionable ? 120 : 0) seconds, but client.send(command:) still
uses the default ~15s socket read timeout; update the socket read timeout to
match waitTimeout before calling client.send(command:) (or call the send
overload that accepts a timeout) so actionable Feed items aren't cut off—locate
the block building params/line and adjust the SocketClient timeout/configuration
around the client.send(command:) invocation.
- Around line 1891-1916: subcommandUsage currently lacks entries for the new
pre-socket commands so "cmux feed --help" and "cmux opencode --help" fall
through to the generic unknown-command message; add cases for "feed" and
"opencode" inside subcommandUsage to return appropriate usage strings (e.g.
"cmux feed clear [--yes]" referencing runFeedClear and "cmux opencode
install-hooks|uninstall-hooks" referencing runOpenCodeInstallHooks and
runOpenCodeUninstallHooks) and ensure the handler recognizes --help/ -h aliases
for those subcommands.
- Around line 14250-14262: The code spawns a Process called process and only
reads pipe.fileHandleForReading.readDataToEndOfFile() after
process.waitUntilExit(), which can deadlock if /usr/bin/diff fills the stdout
pipe; change the logic in this diff-spawning block so stdout is drained
concurrently (e.g., attach a readabilityHandler on pipe.fileHandleForReading or
write diff output to a temporary file) before or while calling
process.waitUntilExit(); ensure you still call try process.run(), handle the
thrown error as before, and return the collected String output as currently
done.

In `@Sources/Feed/FeedPanelView.swift`:
- Around line 323-354: Replace all hardcoded user-facing strings in
FeedPanelView with localized lookups: update primaryTitle cases (the
"ExitPlanMode" and "Question" branches) and any title fallback to use
String(localized:..., defaultValue:...), change helpText components if they
include user-facing labels, and modify resolvedBadgeLabel to localize the
decision labels instead of using rawValue (e.g., localize "feed.badge.plan",
"feed.badge.allowed", "feed.badge.answered" and create separate keys for each
decision variant currently returned via m.rawValue). Do the same for
telemetry/summary strings referenced elsewhere (e.g., "session start", "error",
"todos: ...") so they use String(localized:..., defaultValue:...) and add
corresponding entries into Resources/Localizable.xcstrings with translations;
ensure keys are stable and descriptive and update any code paths using
snapshot.payload, primaryTitle, helpText, or resolvedBadgeLabel to use those
localized keys.
- Around line 491-532: The submit path blocks empty-option questions because
FeedPillButton is disabled when selectedIds.isEmpty; change its enabled logic to
allow submission when options.isEmpty and the row is pending (i.e., set disabled
to !status.isPending || (!options.isEmpty && selectedIds.isEmpty)), adjust
tint/opacity to use the same condition, and ensure the submit action still calls
onReply(Array(selectedIds)) (which will be [] for no-options). Also prevent
UI-only changes after resolution by disabling the option toggle Buttons when
!status.isPending (e.g., apply .disabled(!status.isPending) to the Buttons or
check status.isPending inside their action) so selections cannot change once the
item is no longer pending.

---

Nitpick comments:
In `@Sources/Feed/FeedPanelView.swift`:
- Around line 161-163: Remove the redundant helper snapshotWorkstreamId and
inline FeedItemSnapshot.workstreamId wherever it’s used: delete the
snapshotWorkstreamId(_ s: FeedItemSnapshot) function, replace its call sites
with s.workstreamId, and remove any comments suggesting the snapshot lacks
workstreamId; do the same cleanup for the duplicated helper variant used in the
other location (the second helper around lines 245–260) so all callers use
FeedItemSnapshot.workstreamId directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d017c548-eb83-4e01-9ef7-76b193a391ef

📥 Commits

Reviewing files that changed from the base of the PR and between 5359282 and 677429b.

📒 Files selected for processing (2)
  • CLI/cmux.swift
  • Sources/Feed/FeedPanelView.swift

Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift Outdated
Comment thread Sources/Feed/FeedPanelView.swift
Comment thread Sources/Feed/FeedPanelView.swift Outdated
Both installers were printing the preview banner but never calling
readLine — they'd write the file immediately regardless of the user's
intent, so `cmux setup-hooks` appeared to skip the y/N prompt for
brand-new installs (the OpenCode plugin and the codex_hooks toggle).
Now both ask "Proceed? [y/N]" after the preview and abort on anything
other than a y-prefix response. Matches installAgentHooks' behavior.
Also short-circuits with "already up to date" when the content
matches, consistent with the agent-hook path.
@socket-security

socket-security Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

This branch was successfully deployed

1 active deployment
Preview — 451cb580 Deployed Apr 26, 2026 by vercel[bot]
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