Files
Harry Yao 67f9db8bc8 Implement Claude Code permission mode switching (#4018)
* feat(web): switch claude-native permission mode mid-session

Claude Code's permission mode could only be chosen when starting a
session: `--permission-mode` is a launch flag, so a running session was
stuck in whatever mode it booted with. Switching to auto mode meant
either attaching to the tmux pane and pressing shift+tab, or ending the
session and starting a new one.

Claude Code exposes no non-interactive mode command (`/permissions`
opens an interactive rules dialog, and settings files are read only at
startup), so the switch drives the TUI's own shift+tab cycle:

- `claude_native_bridge.set_permission_mode` presses `BTab` and reads
  the mode footer Claude renders below its input box after each press,
  until the target mode appears. The cycle is walked rather than
  computed from a press count because its width varies — `auto` is only
  in it for accounts that have the mode, and `bypassPermissions` only
  when the session launched into it. `dontAsk`/`bypassPermissions` are
  rejected up front as unreachable.
- The runner dispatches a `permission_mode_change` event and echoes back
  the mode the pane actually landed on.
- PATCH /v1/sessions accepts `permission_mode` and persists it as a
  label only after the runner confirms the switch, so the UI can never
  claim auto mode while Claude is still prompting on every edit. The
  label (not `terminal_launch_args`, which records only launch flags) is
  what the UI reads back after a reload.
- The composer grows a mode picker for claude-native sessions, mirroring
  the existing Codex plan-mode toggle.

The prompting mode is relabelled "Manual" to match what Claude Code
calls it in its own UI and renders in the pane footer; the wire value
stays `default`.

Test Plan
- New bridge tests cover the walk, the no-op when already in the target
  mode, an unreachable mode, non-cycleable modes, a pane with no footer,
  and a stale footer mid-repaint (this last case was a real bug found
  against a live TUI: reading the pre-keystroke mode made the cycler lag
  a mode behind and report `auto` unreachable).
- New runner tests cover the dispatch, the 503 on a failed switch, and
  the non-native no-op; new server tests cover the forward, the
  label-only-after-confirmation rule, and both rejection paths.
- Verified end-to-end against a real `claude` 2.1.220 TUI in tmux:
  every mode reached and confirmed in the pane, including a repeated
  request for the mode already active.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* feat(web): mirror in-terminal permission-mode switches to the UI

A shift+tab pressed inside the Claude Code TUI never reached the web UI:
only UI-driven switches stamped the mode label, so the composer's picker
showed a stale mode until the next switch from the web side.

Claude Code emits no event on a mode change and hook payloads only arrive
on tool use, so the rendered mode footer is the only available signal. The
forwarder polls it (throttled to 2s — each read spawns a tmux capture-pane
subprocess) and POSTs `external_permission_mode_change` when it differs
from the last reported mode. The server persists the label and publishes
`session.permission_mode` so the live picker follows the pane and a
reloading client restores the same state.

Anchor the footer scan on the input box's closing rule instead of a fixed
tail offset: the footer's height scales with concurrent subagents, and the
anchor also excludes transcript text structurally, so a mode name Claude
echoed while discussing modes can't be misread as live.

The first observation after spawn is the launch mode, not a switch, so it
seeds the baseline without posting — otherwise a passive spawn default
could clobber a mode the web UI just set. An unreadable pane reads as
"unknown" rather than a guess, and the picker hides instead of displaying
a mode the session may not be in (a `permissions.defaultMode` in a
settings file never reaches the launch args).

Also move the picker into the session-config gear modal alongside model
and effort, keeping the composer row uncluttered.

Fix: `external_permission_mode_change` was missing from the events route's
payload-validation passthrough, so every forwarder POST was rejected with
a 400 before reaching its handler — and the forwarder swallows HTTP errors
at debug level, so the mirror failed silently. Added an end-to-end test
that drives the real forwarder against a live server and asserts the event
crosses the actual SSE wire, since each layer's own mocked tests passed
while the seam between them was broken.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* test: drop redundant permission-mode cases and tighten comments

The `sse.test.ts` block duplicated `sessionEvents.test.ts`, which pins the
same envelope through `parseEventLines` and is the file whose stated job is
catching silent lift bugs. The composer case asserting the absence of the
old standalone picker guarded a selector that no longer exists anywhere.

Rewrite the comments that described the change instead of the code: the
e2e docstring recounted how the 400 was found, and a composer comment
narrated what the gear "used to" do.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(server): don't 500 on a silent permission-mode PATCH

`silent` suppresses the live runner forward, so no mode is confirmed and no
label is written — but the publish read `labels_to_set[...]` unconditionally
and raised KeyError, turning a request that changed nothing into a 500.

Gate the publish on the label actually being set. That also keeps an
unconfirmed mode off the picker, matching the rule the forward path already
follows: the label is written only once the pane really moved.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(ci): address all 4 failing CI checks on PR #4018

- Regenerate openapi.json to include the new SessionPermissionModeEvent
  type and permission_mode field on UpdateSessionRequest; fixes Pytest
  (server-rest) openapi drift check.

- Update E2E landing-page permission-mode test: the default mode label
  was renamed from "Default" to "Manual" in claudePermissionMode.ts;
  fixes E2E UI Tests (shard 2/3).

- Add test_claude_native_permission_mode_switch_persists to cover the
  new in-chat permission-mode picker in the gear modal; extends
  _patch_session_as_claude_native with a permission_mode param so the
  label stub is set; resolves the E2E UI Required gate.

- Raise the asyncio yield deadline in the quiescence-recheck test from
  10 000 to 50 000 to eliminate flaky timing failures on slower CI
  runners; fixes Pytest (misc).

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(claude-native): show the permission picker for manual-mode sessions

A session launched in manual mode had no way to leave it. Manual is the
default and writes no `--permission-mode` arg, and the forwarder discarded
its first pane observation as "just the launch mode", so nothing ever
recorded the session's mode. With no mode to render the web picker hid
itself, and the switch could never be started — not because cycling
failed, but because there was no control to press.

Post the first observation instead of seeding silently, so every live
claude-native session publishes its mode within one poll whatever it
launched in, CLI `omnigent claude` included. The overwrite this guarded
against cannot happen: the server ignores a mode equal to the stored
label, and the PATCH path only persists after the runner confirms the
pane repainted, so a stored label already matches the pane.

Verified against a real Claude Code 2.1.237 pane: manual renders
`⏸ manual mode on`, and shift+tab cycles manual → accept edits → plan →
auto → manual, matching the bridge's footer table exactly.

Three tests had encoded seed-silently as intended behavior and now assert
the launch mode reaching the wire.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* test(e2e): locate permission-mode options by data attribute

The picker's options render their label and description in nested spans, so
each option's accessible name is "Auto Auto-runs; a classifier blocks risky
actions" — never the bare label. `get_by_role("option", name="Auto",
exact=True)` could therefore never match, and the click timed out after 30s.

Tag each option with `data-permission-mode` and select on that, matching how
the sibling model and effort rows already expose `data-model-id` and
`data-effort-level` for the same reason.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

---------

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-20 23:14:53 -07:00
..

web

The web UI for omnigent server --agent <agent>. SPA built with Vite + React + TypeScript + Tailwind v4 + shadcn/ui. Talks to the current Omnigent API surface (/v1/agents, /v1/sessions, session-scoped /v1/sessions/{id}/resources/files).

Develop

In one terminal, start the omnigent server (default port 6767). Use --agent to pre-register one or more agents at startup (accepts a YAML file or an agent-image directory; can be repeated):

.venv/bin/omnigent server --agent examples/hello_world.yaml

In another terminal, start the Vite dev server (port 5173):

cd web
pnpm install
pnpm run dev

The Vite dev server proxies /v1 and /api to http://localhost:6767. Set OMNIGENT_URL to override the proxy target:

OMNIGENT_URL=http://localhost:9000 pnpm run dev

To develop against a Databricks workspace-hosted server, point OMNIGENT_URL at the bare workspace origin — the dev proxy fills in the /api/2.0/omnigent mount and authenticates with your databricks auth login token automatically:

OMNIGENT_URL=https://my-workspace.databricks.com pnpm run dev

Additional omnigent server options:

Flag Default Description
--host 127.0.0.1 Host to bind to
-p / --port 6767 Port to listen on
--database-uri <data-dir>/chat.db Database URI for stores
--artifact-location <data-dir>/artifacts Path for artifact storage
-c / --config (none) Path to YAML config file
--execution-timeout 7200 Max wall-clock seconds per execution
--agent (none) Pre-register an agent (repeatable)

Build + serve from the Omnigent server

cd web
pnpm run build

Vite writes the bundle to ../omnigent/server/static/web-ui/ (configured in vite.config.ts). When that directory exists and contains index.html, the FastAPI app in omnigent/server/app.py mounts it at /. After a build:

.venv/bin/omnigent server --agent examples/hello_world.yaml
# open http://localhost:6767/

Lint + format

pnpm run lint          # oxlint .
pnpm run lint:fix      # oxlint --fix .
pnpm run format        # prettier --write .
pnpm run format:check  # prettier --check .
pnpm run type-check    # tsc -b

pnpm run type-check runs in CI as part of the Pre-commit checks job (.github/workflows/lint.yml) and gates merge. Run it locally before committing any change under web/.

Test

pnpm run test          # vitest run
pnpm run test:watch    # vitest in watch mode

Reducer parity

The TypeScript reducer at src/lib/blockStream.ts is a hand-mirror of the Python reducer at sdks/python-client/omnigent_client/_stream.py. Same for:

TS file Mirrors
src/lib/blocks.ts omnigent_client/_blocks.py
src/lib/events.ts omnigent_client/_events.py
src/lib/types.ts minimal subset of omnigent_client/_types.py
src/lib/sse.ts omnigent_client/_sse.py
src/lib/blockStream.ts omnigent_client/_stream.py
src/lib/blockStream.test.ts tests/frontends/sdk/test_stream.py

There is no cross-language CI gate today. When _stream.py changes for a real bug (e.g. new harness quirk, dedup edge case), the TypeScript port can lag — drift surfaces only when someone next runs pnpm run test after a behavioral change. Workflow when _stream.py changes:

  1. Read the diff to _stream.py (or _blocks.py / _events.py).
  2. Update blockStream.ts (or blocks.ts / events.ts) to match.
  3. Add or update a case in blockStream.test.ts that pins the new behavior — same shape as test_stream.py.
  4. pnpm run test → green.

If we ever decide cross-language fixture parity is worth the maintenance burden, we'd port the captured-fixture approach used for test_stream.py.

web-only divergences

web carries a few constructs the Python SDK doesn't, on purpose. They're listed here so a future maintainer doesn't try to "restore parity" by mirroring them across.

  • UserMessageBlock (in blocks.ts) — surfaces persisted user message items as blocks so the bubble walker sees a single flat list. The SDK's BlockStream.stream() never emits user messages (its consumers receive the user input as the caller's own argument, not back through the stream).
  • BlockContext.responseId + BlockContext.itemId — populated by the TS reducer from the SSE wire format (response.created.response.id and event.item.id / event.item.response_id on output_item.done) so each block knows its server origin. The TS events ToolCall / ToolResult / MessageDone / NativeToolCall carry itemId + responseId to thread the values through.
  • Flat block storage in chatStore.blocks, grouped at render time by buildBubbles keyed on ctx.responseId. The SDK has no equivalent — its consumers iterate the block stream procedurally without a stateful store.

When _stream.py / _events.py / _blocks.py change for a substantive reason (new event type, new dedup edge case), continue to mirror the behavioral changes here; just leave the divergences above alone.

Stack

  • Vite + React 19 + TypeScript
  • Tailwind v4 (@import "tailwindcss", no config file)
  • shadcn/ui (radix-nova preset, neutral base, CSS variables)
  • TanStack Query, Zustand, React Router v7
  • streamdown (+ @streamdown/code, @streamdown/math, @streamdown/mermaid), shiki, framer-motion, cmdk, react-hotkeys-hook, use-stick-to-bottom, next-themes, react-hook-form, zod
  • Lint: oxlint. Format: prettier.