* 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>
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:
- Read the diff to
_stream.py(or_blocks.py/_events.py). - Update
blockStream.ts(orblocks.ts/events.ts) to match. - Add or update a case in
blockStream.test.tsthat pins the new behavior — same shape astest_stream.py. 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(inblocks.ts) — surfaces persisted user message items as blocks so the bubble walker sees a single flat list. The SDK'sBlockStream.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.idandevent.item.id/event.item.response_idonoutput_item.done) so each block knows its server origin. The TS eventsToolCall/ToolResult/MessageDone/NativeToolCallcarryitemId+responseIdto thread the values through.- Flat block storage in
chatStore.blocks, grouped at render time bybuildBubbleskeyed onctx.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-novapreset, 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.