`claude_native.py` imported two integer close codes from
`omnigent.terminals.ws_bridge`. That bridge serves the `/attach`
WebSocket, so it imports FastAPI (~120ms) and, through the package
barrel, the tmux registry (~100ms) — all of it loaded on every
`omnigent claude` launch to compare a close code to `4404`.
Move the four 4xxx codes into a dependency-free
`omnigent.terminals.close_codes`. They are the wire contract between the
server route, the runner, the native client, and the browser, so no
consumer should have to import the socket implementation to read one.
Every consumer now imports from the leaf module, leaving one definition
site rather than an implicit re-export.
Also resolve the `omnigent.terminals` barrel's two exports through PEP
562, so importing a leaf module no longer builds `TerminalRegistry` (and
`omnigent.inner.terminal` under it). `from omnigent.terminals import
TerminalRegistry` is unchanged.
`import omnigent.claude_native`: 0.72s -> 0.57s (-150ms), with FastAPI,
Starlette, and the tmux registry no longer in the graph. Reading a close
code loads 85 modules instead of 288.
The guards pin the published code values (a change there is a protocol
break needing the browser mirror updated) and both import boundaries.
Co-authored-by: Isaac <no-reply@databricks.com>
Resuming a conversation whose history contained an assistant message with
plain-string content crashed before reaching the model:
TypeError: string indices must be integers, not 'str'
chatcmpl_converter.py:625 in items_to_messages
A string is legal Responses-API content, but items_to_messages iterates an
assistant message's content expecting blocks. Given a string it walks the text
character by character and indexes each character, so the very first one raises.
User strings are unaffected — they reach extract_text_content, which accepts
them, and callers depend on them staying strings.
Because history is replayed on every turn, one such item ends the conversation
permanently: each retry fails identically before the model is reached, and the
only escape is to abandon the conversation.
Only the assistant branch is normalized, and only when content is a string, so
block content and user strings pass through untouched.
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
* Address ArgoCD overlay review follow-ups (#4977) and PR #4744 comments
Add CI validation of kustomize overlays, clarify the ignoreDifferences
/data vs /stringData ArgoCD normalization, separate sync-completes from
app-healthy in the Ingress wave comment, add TODO(v0.29) to the
bare-Pod fallback in terminate(), and update the sandbox-runners README
to reflect the bare-Pod → Job migration.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(ci): install kustomize via official script instead of third-party action
The pinned SHA for imranismail/setup-kustomize was unresolvable.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(docs): correct backoffLimit value in sandbox-runners README
The README stated `backoffLimit: 0` but the actual code uses
`_JOB_BACKOFF_LIMIT = 6` — fix the doc to match.
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
An ACP agent may ask permission carrying only a `toolCallId` — no `title`,
`kind`, or `rawInput`. Devin does. `_extract_tool_call` then resolved the name to
the literal string "tool" with empty arguments, so:
- the approval card asked the user to approve "Devin wants to use **tool**",
preview `tool({})`, with no command shown; and
- the TOOL_CALL policy was evaluated as `{"name": "tool", "arguments": {}}`,
which no builtin rule can match — rules gate on the tool name before reading
`arguments["command"]`, so a "deny `rm -rf`" policy sat silent.
The originating `tool_call` update carries the real name and command and always
arrives first, and the executor already caches `toolCallId -> name` there to
close the right tool card. Cache the `rawInput` beside it and fall back to both
when the request omits them; values the request does carry still win. The
correlation is the protocol's own id, so no vendor `_meta` key is read.
Both caches are released when the call closes, as the name cache already was.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Omnigent's uvx setup path resolved ucode from the mutable `main` branch, so
setup could silently pick up a new ucode commit between runs and break
unexpectedly. Pin `_UCODE_GIT_REF` to a fixed, known-good commit
(94271a78c7139220b7333bcae91e522f95ef3af3) so setup is reproducible.
A full SHA is immutable, so uvx caches the built wheel by ref and reuses it
across runs; drop the `--refresh-package ucode` that existed only to defeat
the mutable branch's stale cache.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
An imported or otherwise unbound session (no host, no runner) couldn't run from
the web: it read as reachable (so the first message dropped against a runner
that can't start) or dead-ended on the terminal reconnect path.
The fix is mostly server-side liveness. An imported transcript is a
native-harness session that only runs in a runner on a host, never in-process,
so report it as runner_online=false via a new `imported` connectivity marker
(keyed on the omnigent.import.source label — the sibling of the existing fork
`needs_workspace` marker, computed in the same query). With that, the open view
routes to the EXISTING host picker (ResumeWithDirectoryDialog) instead of the
dead end. That picker — the same one forks and new-chat use — binds the session
to an online host + workspace (defaulting to the caller's current host) and
launches a runner via the existing POST /v1/hosts/{id}/runners path. No new
host-selection UI, no new launch route.
The picker is offered only when the resume will actually work
(unboundSessionResumableInApp): the caller must OWN the session (launch_runner
requires owner — a shared non-owner 404s), and for imports the harness must
reconstruct context from the omnigent transcript so it carries onto a chosen
host. Kimi has no resume path, and kiro/qwen resume only from a local recording
that lives on the original machine, so those route to the terminal reconnect
path instead of a picker that would start blank.
Also:
- Skip the cold-boot startup grace for imports so the picker shows at once.
- Generalize ResumeWithDirectoryDialog to prefill from the session's own fields
when there is no fork source.
- `omnigent import` prints the session's browser URL instead of the bare id.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
Co-authored-by: Mark Tai <mark.tai@databricks.com>
The "A host daemon is already running for this server" error suggested
`omnigent host stop --server ...`, where the literal `...` hid the fact
that `--server` needs an argument and left users guessing which value to
pass. Build the hint via the existing `_host_stop_command` helper from
the conflicting record, so the message prints a ready-to-run command:
the real URL for a remote daemon, or `--server ""` (the empty-string
alias) for a local daemon, matching the `host --background` hint.
Co-authored-by: Isaac
Signed-off-by: Evelyn Hur <122575337+evelyn-hur@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
* Move tasks to chat box
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* Remove tasks from tab
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* padding
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test(web): e2e test for the in-chat Plan tracker
Drives the real chat store (mocked todos) through ChatPlanAccordion:
collapsed by default, expands to the task list, tracks a live
completion-count update, and self-hides when the list is cleared.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test(server): cover per-item todo validation filter; fix e2e-test lint
Adds a pytest case proving _handle_external_session_todos drops
malformed todo items (bad status / non-str content / non-str
activeForm / non-dict) while keeping well-formed ones, on both the
session.todos SSE channel and the cached snapshot — the one todos-
pipeline path the existing tests didn't exercise.
Also switch the ChatPlanAccordion e2e test's Todo `type` to an
`interface` to satisfy oxlint (consistent-type-definitions).
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test(e2e_ui): browser e2e for the in-chat Plan tracker
Move the tracker's e2e coverage into the Playwright suite where it can
exercise the real UI: tests/e2e_ui/chat/test_plan_tracker.py seeds the
session.todos contract through the events route (the forwarders' path),
then asserts the pinned Plan card seeds from the snapshot on load, stays
collapsed by default, expands to the task list on click, tracks a live
completion count, and disappears when the list clears. Mirrors
test_mcp_startup_indicator.py's seed-then-republish pattern.
Adds a data-testid="plan-tracker" hook to ChatPlanAccordion, and drops
the jsdom vitest e2e (web/.../ChatPlanAccordion.e2e.test.tsx) it
supersedes; the ChatPlanAccordion unit test stays.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* docs(web): align Plan accordion max-height comment with code
The comment said "Cap the expanded list at 100px" while the class is
max-h-[150px]; sync the number (flagged by Polly review). Comment-only.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
---------
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
A single $ is prose far more often than a math delimiter — currency,
rates like $/PR and $/session, shell variables — and single-dollar math
paired any two of them up, rendering everything in between as
letter-by-letter math soup. Require $$ to open math and drop the
currency/env-var escaping heuristics that tried to guess prose apart from
math. Explicit TeX delimiters now normalize to $$ so \(x\) still
renders as inline math.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(changelog): record v0.10.0
* docs(changelog): fix truncated and malformed entries in v0.10.0
Complete 18 truncated entries, add proper [Bug fix / Test/CI] tags to
#4508 and #4509 (which had bare `*` bullets), and drop the internal-only
[Docs] N/A entry (#4925).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): keep the Working shimmer lit while background tasks run
The background-tasks pill (#4893) introduced a shared `isBackgroundTasksOnly`
predicate that gated all three busy surfaces off `bgCount > 0` alone, without
checking whether the agent's turn was still active. So any live turn that
coincided with a background task — notably `waiting`, where the parent is
parked on its async-work drain of sub-agents / background shells — had its
"Working…" shimmer suppressed and replaced by the pill, misreading an active
turn as finished.
Make the shimmer and the pill independent surfaces:
- `isBackgroundTasksOnly` now also requires the turn to be inactive
(`!agentWorking`), so the shimmer yields only once the turn has genuinely
ended (`idle`) with tasks lingering.
- `BackgroundTaskPill` shows on `bgCount > 0` alone, decoupled from the shimmer,
so both appear together while the turn is active.
- `workingIndicatorLabel` no longer emits the background count (the pill owns
it); the shimmer just rotates its working messages or shows "Blocked on: …".
Co-authored-by: Isaac
* fix(web): keep the background-task pill lit while the turn works
The pill vanished the moment the "Working…" shimmer appeared, so the two
surfaces were still effectively mutually exclusive. The cause was the
background-shell tally being zeroed on every new turn: the server's
_publish_status popped the cache on a `running` edge, the client's
session_status reducer zeroed it on `running`, and the optimistic send path
cleared it synchronously. All three date from the single-surface design, where
the count was a LABEL on the shimmer ("N background tasks still running") that a
new turn should replace with "Working…".
Now that the pill is a separate surface, background shells outlive turn
boundaries and the tally must persist across the turn so the pill stays lit
beside the shimmer. Stop clearing on `running` in all three places; keep
clearing only on an authoritative Stop-hook `0` (shell finished) and on
`failed` (a dead session may never post another count). The next Stop hook
re-reports the count authoritatively.
Also note the server normalizes a claude-native turn-end `waiting`+count to
`idle` (see _background_task_delivery_status), so the client's real
"working + shell" state is `running` with a preserved count — reflected in the
reworked e2e coverage.
Co-authored-by: Isaac
* fix(web): remove the scroll-pinned Working tab
The pinned "Working…" tab (shown while scrolled up) was designed to merge its
flat bottom edge into the composer, but the background-task pill now sits
between them — so the tab reads as a stray rounded card floating above the
pill. Remove the sticky tab entirely (WorkingStatusPin); the inline shimmer at
the end of the thread is the working cue.
Move the tab's one non-visual job — the sole aria-live region announcing the
working state — onto the inline WorkingIndicator: a stable "Working…" in a
role=status region, with the rotating visible label kept aria-hidden so it
never re-announces. Screen readers still get one announcement per turn.
Co-authored-by: Isaac
## Related issue
Closes [OMNI-2964](https://linear.app/omnigent/issue/OMNI-2964/fix-host-tunnel-connection-issue-when-it-fails-to-detect-hanress)
## Summary
- Move harness and gateway capability discovery out of reconnect handshakes, bound startup discovery, and degrade probe failures to visible warnings with unknown metadata.
- Add a backward-compatible `host.connection_error` frame so accepted tunnels can surface server-side setup failures with their stage and retryability.
- Make background startup wait for the existing server-side host status before reporting success and retain reconnect regression coverage.
ELI5: checking which agent CLIs are installed is optional setup information. A broken CLI should not prevent the host from introducing itself to the server, so the host now connects with that information marked unknown and refreshes it later.
```text
host startup ── capability probe ──┬─ success → cached metadata
└─ failure/timeout → warning + unknown
│
▼
WebSocket upgrade → host.hello → connected receive loop
▲
server setup failure → host.connection_error
```
## Test Plan
- `uv run pytest tests/host/test_frames.py tests/server/integration/test_host_tunnel_route.py tests/host/test_connect.py tests/host/test_cli_host.py -q`
- `uv run pytest tests/host/test_connect.py::test_silent_connect_streak_escalates_and_slows_reconnects tests/host/test_connect.py::test_inbound_frame_resets_silent_connect_streak -q`
- `uv run ruff check` on all changed Python and test files.
- `uv run pyrefly check omnigent/host/connect.py omnigent/host/frames.py omnigent/server/routes/host_tunnel.py omnigent/cli.py`
## Demo
N/A — backend/CLI reliability change with no visual UI.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [x] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Automated coverage exercises capability exceptions and timeouts, server error propagation, background registration checks, retryability, and silent reconnect backoff.
## Changelog
`omnigent host` now stays connected when optional harness detection fails and surfaces server-side tunnel setup errors.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* feat(cli): gate naked omni invocations behind a wrapper guard
Operators who front the CLI with a wrapper (e.g. `isaac omni`) can set OMNIGENT_REQUIRE_WRAPPER to refuse direct `omni`/`omnigent` calls. The wrapper sets OMNIGENT_WRAPPER_BYPASS around its own invocation to pass through, and OMNIGENT_WRAPPER_COMMAND names the command to suggest in the block message. The guard runs at the top of main() before any work, and is covered by unit tests on the message logic plus subprocess e2e tests for the block and bypass paths.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
* style(cli): drop stray blank line left by the main merge
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
---------
Signed-off-by: Mark Tai <mark.tai@databricks.com>
Co-authored-by: Mark Tai <mark.tai@databricks.com>
Clicking "Run on this machine" looped back to a "No hosts" error whenever
the desktop's stored Databricks OAuth grant had expired, forcing the user
to run `omni` in a terminal to complete the browser sign-in.
Root cause: serverAuthed() treated any Databricks pointer record as
authed without checking token freshness, so ensureServerAuth skipped
`omnigent login`. The spawned `omnigent host` (no TTY) then hit the
non-interactive auth guard and exited pre-connect, and connectThisMachine
returned silently — stranding the user on "No hosts".
Fix (contained to the desktop shell + web UI; no shared CLI change):
- ensureServerAuth now decides "auth needed?" with a GET /v1/me probe
(probeServerAuth) — the same signal the CLI's own pre-flight trusts —
instead of the stale on-disk token file. When not authed it runs the
idempotent `omnigent login`, which silently refreshes a live grant with
no browser and only opens the browser for a genuine re-auth.
- Spawn `omnigent host --non-interactive` so any residual auth gap fails
loudly with a classifiable authError rather than hanging on a missing
TTY. (No Python change — the flag already exists.)
- Surface the failure in the New Chat dialog with a "Try again"
affordance instead of returning silently; auth failures get
sign-in-flavored copy. Threads authError through the host-control IPC
result and HostActionResult.
- Raise the login timeout 180s -> 305s so a human completing the browser
sign-in isn't SIGKILLed mid-flow (the CLI's own OIDC deadline governs).
Tests: probeServerAuth (status/redirect/token branches), ensureServerAuth
(loopback/authed/unreachable/login-success/login-failure), and the New
Chat dialog's error surfacing + retry.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The pinned "Working…/Tinkering…" tab (WorkingStatusPin) used `bg-card`,
which in dark mode is a translucent glass surface: `--card` is
rgba(31, 39, 45, 0.6) and the global `.dark .bg-card` rule adds a
backdrop-blur. Over the transcript the tab read as a see-through frosted
pill floating above the composer — most visible on mobile.
Switch the tab to `bg-card-solid`, the opaque `--card` variant the
composer itself uses in dark mode. This makes it opaque and, by not
matching the `.dark .bg-card` glass rule, lets its `border-b-0` actually
merge flush into the composer instead of the glass rule re-adding a
bottom edge. Light mode is unchanged (`--card` and `--card-solid` are
both #fff).
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(deploy): add ArgoCD overlay for kubernetes sandbox provider
Add a Kustomize overlay that layers sync-wave annotations onto the
sandbox-runners overlay so ArgoCD deploys resources in dependency order
(namespaces → RBAC → config → Deployment). Includes a sample Application
CR and documentation for quick-start, out-of-band credential management,
and multi-environment setups via ApplicationSet.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(deploy): address ArgoCD overlay review feedback
- Remove over-engineered sync waves; ArgoCD's built-in kind ordering
already sequences Namespace → SA → Role → ConfigMap → Deployment.
Waves added health gates that caused PVC deadlock (WaitForFirstConsumer
blocks until a consumer Pod is scheduled) and Ingress stall (no
controller → Progressing forever).
- Switch from 13 name-pinned strategic merge patches (which fail silently
into wave 0 on a rename) to 3 kind-regex JSON patches (31 lines vs 151).
- Add Prune=false on Namespaces and PVC to prevent accidental cascade on
Application deletion or stale targetRevision.
- Add ignoreDifferences for omnigent-secrets (selfHeal was reverting
operator credentials to the checked-in placeholder) and PVC storage
(API server mutations cause perpetual SyncFailed).
- Fix syncOptions: remove inert CreateNamespace=true (destination.namespace
is unset), correct RespectIgnoreDifferences comment to reference the
actual ignoreDifferences block.
- Restructure README quick start around fork-and-push (local edits have no
effect when ArgoCD reads from Git), add namespace wait between Application
apply and Secret creation, document auth prerequisite (accounts provider
403s on managed runner dial-back), fix "delete Ingress" advice to use
$patch: delete instead of removing base/ingress.yaml (which breaks all
overlays), fix postgres composition advice (direct resource causes
duplicate-base error), document deletion cascade and selfHeal behavior.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
#3943 replaced the unconditionally-fatal 403 with a retry streak, which
is strictly better than exiting on the first rejection but has no
ever_connected condition — so a runner that already completed an upgrade
still dies once three rejections land consecutively. Because delay_s is
reset to the base delay on every rejection, those three attempts land
within a few seconds, so a brief connectivity blip is enough to exhaust
the streak: healthy tunnel to dead process in 8 seconds.
Dropping off a VPN reproduces it — an intermediary answers the WS upgrade
with 403 before the request reaches the server. The same runner survives
or dies depending purely on whether the token refresh wins the race
against the streak, and the error text tells the user to re-authenticate
when the credentials were valid the whole time. The exit takes down every
conversation on the runner, not just the active one, and being ungraceful
it leaks detached terminal tmux servers until a later runner's
reap_orphaned_terminals() sweep.
The host tunnel already got this treatment in #4025: a tunnel that
completed an upgrade proved its credentials, so a later 401/403 is a
network-path artifact and retries indefinitely rather than forcing a
manual restart. The runner path was one surface behind; this applies the
same posture:
- The fatal streak now applies only before the first successful upgrade.
A never-connected runner still fails loud after three rejections, so
a genuinely-forbidden runner does not busy-reconnect forever.
- An already-connected runner keeps the escalating backoff instead of
resetting to the base delay, so a sustained outage retries at the 10 s
cap rather than hammering the rejecting proxy every ~0.5 s.
- The retry logs at WARNING and names VPN/network as the likely cause,
so a genuinely revoked credential is not silent to an operator.
Token invalidation still runs on every rejection, so a plain mid-session
expiry recovers on the next attempt as before.
Continues #3516, which identified this fix before #3943 landed and went
stale against it. That PR's ever_connected guard is reapplied here on
top of #3943's streak structure, and its host-bootstrap-bearer test is
carried over; the rest of its diff was superseded upstream.
Tests: an already-connected runner survives a rejection streak well past
the fatal bound and escalates 0.5→10 s; a 403-rejected host bootstrap
bearer is swapped for the runner's own refreshable token. The existing
never-connected fatal tests are unchanged and still pass.
Co-authored-by: Isaac
Signed-off-by: Anton Nekipelov <226657+anton-107@users.noreply.github.com>
Co-authored-by: Bryan Qiu <bryan.qiu@databricks.com>
test_launch_cancelled_midspawn_does_not_leak_untracked_runner signals
spawn_started after Popen returns, then cancels the launch task. On a
loaded machine the event loop is descheduled in that gap, _handle_launch
runs to completion, and the cancel arrives after the window it is meant
to exercise, so the test fails with "DID NOT RAISE CancelledError"
instead of catching a leak.
Hold the spawn thread inside the shielded call until the test has issued
its cancel, so the cancel lands in the leak window regardless of
scheduling. The assertions are unchanged, and the test still exercises
the real post-spawn/pre-register window it was written for.
Reproduced by inserting a 0.2s sleep between the spawn signal and the
cancel, which fails identically to CI; with this change the same
insertion passes.
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
Co-authored-by: Isaac <no-reply@databricks.com>
* fix(web): back off silent sticky-apply PATCHes when the backend errors
The sticky model/effort applies in bindStream and
refetchRunnerBackedSessionState fire on every bind/switch while the
session's server-side override is still null. When the backend is
erroring the PATCH never persists, so the null-override guard never
closes and the applies re-fire on every rebind. During an outage that
becomes a self-sustaining PATCH storm with no backpressure: the failures
are swallowed (fire-and-forget .catch), so nothing slows down.
Add a failure-scoped, auto-clearing client backoff. A backend-unhealthy
failure (5xx / network / timeout) pauses the silent applies for a
cooldown; a 404 parks that gone session; the next success clears the
cooldown so stickiness resumes the moment the backend recovers. A
successful send-path bind also clears it, and it feeds a failing bind
into the same backoff. Normal operation is unchanged — the PATCH
succeeds on the first try and nothing ever arms.
Refs OMNI-2513.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(web): let a 404-parked sticky-apply recover on the next successful bind
The silent sticky-apply parks a session on a 404 so its failure doesn't
pause the others. But nothing lifted that park except a page reload: the
sticky applies that would clear it are themselves gated by the park, so a
parked session could never re-apply.
A sticky PATCH only runs after a successful snapshot GET, so a 404 there is
a transient mid-bind race rather than a durable "gone". Lift the park when
bindStream's snapshot GET next succeeds (proof the session exists); if it is
genuinely gone that GET 404s and bindStream bails before any PATCH, so there
is no storm either way. Also treat 410 Gone like 404.
Refs OMNI-2513.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(web): treat a sticky-apply 404 as a transient backend failure
A 404 on the silent sticky-apply PATCH does not mean the session is gone:
here it means the permission check didn't succeed (a flaky permission
service), which is backend-wide and transient — the same root cause as the
5xx errors seen in the same outage. So a 404 must pause every session's
applies via the global cooldown, exactly like a 5xx, rather than parking
the one session that happened to 404.
Collapse the per-session gone-set into the single global cooldown: every
failure (4xx incl. 404, 5xx, network) arms it; the next success clears it.
This removes the recovery machinery the per-session park needed — the
cooldown is inherently self-clearing — and suppresses more of the storm
during a real outage (the first failure pauses all sessions instead of
letting each fire once before parking).
Refs OMNI-2513.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(web): reopen the sticky-apply cooldown by time, not on a success
During the outage ~90% of requests failed, so ~10% still succeeded. With
the cooldown clearing on any success, each of those lucky successes would
reopen the gate and let the next (still-likely-failing) sticky apply fire —
a flap that leaks a fresh apply on every success rather than holding.
Arm the cooldown on failure only and reopen it purely by elapsed time; a
success no longer clears it, so the successful fraction mid-outage can't
flap the gate. This also drops the send-path from the cooldown entirely
(it fails loudly on its own) and removes the success bookkeeping. Recovery
is the window elapsing (≤30s), which is fine for a cosmetic sticky apply.
Refs OMNI-2513.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(web): keep the /model readout honest while sticky-apply is cooling down
The sticky-model apply is skipped during the cooldown, but the readout
still computed effectiveSessionOverride from the sticky model, so the
/model picker briefly claimed an override the server never persisted —
the inverse of the honesty this change is about.
Fold the cooldown check into willApplyStickyModel so the readout and the
PATCH decision share one condition: while blocked, we neither apply nor
claim the override, and effectiveSessionOverride stays null to match the
un-persisted server truth.
Refs OMNI-2513.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
---------
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* feat(k8s): replace bare Pods with Jobs for automatic failover
The Kubernetes sandbox launcher previously created bare Pods with
restartPolicy: Never. A crashed host container was a dead end until
a human retried. This change wraps the Pod template in a batch/v1 Job
with restartPolicy: OnFailure and a configurable backoffLimit (default 3),
so the kubelet automatically restarts a crashed host container with
exponential backoff — providing automatic failover without a custom
scheduler or work queue.
Key changes:
- build_pod_manifest() → build_job_manifest(): wraps the Pod spec in a
Job with backoffLimit, activeDeadlineSeconds, and a liveness probe
(pgrep -f "omnigent host") to detect stuck processes.
- KubernetesSandboxLauncher now uses BatchV1Api alongside CoreV1Api.
- start_host() creates a Job; _wait_for_pod_running() discovers the
Job's child Pod via the job-name label selector.
- terminate() deletes the Job with propagationPolicy: Foreground,
cascading to its child Pods.
- RBAC Role updated: added batch/v1 Jobs (create/get/delete), changed
Pods from create/get/delete to list/get (Pod lifecycle is now managed
by the Job controller).
The host's existing WebSocket reconnect logic re-registers the tunnel
automatically after a container restart, and the runner's durable
conversation checkpointing recovers incomplete turns on session re-init.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix: ruff format + unused variable lint
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(k8s): address reviewer feedback on Job migration
- RBAC: retain pods create/delete for one-release upgrade overlap window
- Drop ineffective liveness probe (pgrep matches reaper's own argv)
- Add bare-Pod delete fallback in terminate/best-effort for pre-migration
sandboxes (Job 404 → try deleting the old bare Pod)
- Restore dropped inline comments explaining security decisions
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(k8s): address reviewer blocking feedback on Job migration
1. **Stale Pod references**: update module docstring, `_new_pod_name`,
`provision`, role.yaml header to reflect Job model. Rename
`_POD_DELETE_*` → `_DELETE_*`. Add version to TODO(v0.29).
2. **`_terminal_failure` reworked for OnFailure**: init container non-zero
exit is no longer terminal unless Pod phase is `Failed` (backoffLimit
exhausted). CrashLoopBackOff on the host container is detected even
though the Pod stays in phase `Running`. `_wait_for_pod_running` now
checks `_terminal_failure` BEFORE accepting `Running`.
3. **terminate no longer leaks Secrets**: each delete is independently
try/caught so a 403 on Job delete still cleans up the Secret. The
first error is re-raised after all deletes run.
4. **Child-Pod discovery hardened**: `_find_job_pod` re-raises 401/403
(surfaces RBAC immediately), filters out Pods with deletionTimestamp,
prefers Running phase. `_wait_for_pod_running` re-discovers on 404
instead of treating it as terminal (supports Pod replacement under
eviction/drain). 403 hint updated to include `jobs`.
5. **backoffLimit raised to 6**: comment clarifies it is a lifetime
budget shared with init containers; 6 leaves headroom for init
retries while still surfacing persistent crashes.
68 tests (62 updated + 6 new).
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
A configured agent named "Devin" slugifies onto `devin`, which is also an
`ACP_CLI_HARNESSES` row id, so both sources describe the same harness by the
same name. They failed in opposite directions:
- the web picker showed one row, silently the builtin — both seed the same
`builtin_agent_id`, and the row seeded second overwrote the user's entry,
dropping the `--model` their command carried;
- `omni setup` showed two identically labeled "Devin" rows, one per source.
The configured agent wins in both: it names the exact command, which a row's
fixed argv cannot express. `shadowed_builtin_acp_rows` states the rule once and
both surfaces read it, matching row ids only — an alias-shaped name ("Grok
Build" -> `grok-build`) is a separate harness id and does not shadow `grok`.
Listing only. `--harness devin` and `harness: devin` specs still resolve to the
row, and removing the config entry brings the row straight back.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(databricks): resolve harness launch models from the workspace
The Databricks AI Gateway has retired the legacy `databricks-*` model
namespace (`501 NOT_IMPLEMENTED ... Use Unity Catalog model services (v3)`).
Several managed harnesses take their launch model from the bundled MLflow
provider catalog, whose Databricks ids carry exactly that retired spelling,
so every gateway turn fails. `claude-native` was migrated to live Unity
Catalog discovery in July; its siblings were left behind.
- codex-native: `_resolve_databricks_codex_model` resolves through the live
UC model-services listing (ids are `system.ai.` by construction), then
ucode's cached copy, then the bundled catalog as a documented last resort.
An explicit legacy `model_override` is matched against the servable ids on
the bare id, so it recovers instead of failing forever; a model the
workspace does not serve passes through untouched.
- claude-sdk (Polly, Debby): resolve the launch model from the live listing
using the family precedence claude-native itself falls back to. And on a
real Databricks AI Gateway, negotiate betas (`CLAUDE_CODE_USE_GATEWAY`)
instead of setting `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, which made
Claude Code strip `interleaved-thinking` and the gateway reject the blocks
with `400 ... Expected 'thinking'`. Unset an inherited disable flag around
the spawn, scoped to gateway launches; a non-Databricks/mock gateway keeps
the original workaround.
- pi-native: resolve the launch model from the live listing.
- model_catalog.fetch_databricks_model_service_entries: scope the UC listing
to `schemas/system.ai` and paginate. Unscoped and unpaged it walked the
whole metastore and returned one page of whatever schemas sorted first, so
a workspace serving 53 models reported 2 and zero Claude entries. A repeated
page token returns the pages collected so far (a partial `system.ai` list
still launches) rather than raising, since callers treat an exception as
"no listing" and fall back to the retired `databricks-` catalog.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* test(databricks): keep codex build test offline
build_codex_native_server now resolves the launch model through live
Unity Catalog discovery, so a build with a profile makes a real
model-services call. test_build_codex_native_server_uses_profile_host_without_static_token
passed only on a machine with ambient Databricks credentials and crashed
the CI worker on the network call. Stub discovery offline; the test
asserts the profile-host base URL + auth command, not model resolution.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
---------
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
The Devin row's comment and auth hint both implied an environment variable can
configure or authenticate the agent (`DEVIN_MODEL`, "or set a Devin API key").
It cannot: the generic ACP spawn env is deny-by-default with no allowed prefixes,
and a catalog row has no `env_passthrough` of its own — only a user-configured
`acp:<slug>` agent can declare one. Verified against the real builder:
builtin row -> DEVIN_MODEL forwarded: False
acp: agent declaring it -> DEVIN_MODEL forwarded: True
Devin is unaffected in practice because `devin auth login` writes a credential
file it reads back at spawn, so state the file-based path instead and point a
per-model setup at an `acp:<slug>` agent carrying `--model`.
Also record the constraint once in the module docstring, since it decides whether
a future vendor can be a row at all: env-var-only vendors need a user-configured
agent, disk-credential vendors work as rows.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(harness): add Devin as a builtin ACP CLI harness
Devin (Cognition's `devin` CLI) speaks ACP on stdio via `devin acp`, so it is
one catalog row — like Grok Build. This makes Devin a first-class harness: it
shows in `omni setup` (own auth, `devin auth login`), launches via
`--harness devin`, and — with this PR's picker seeding — seeds into the web New
Chat picker once the `devin` binary is on PATH, with no user `acp:` config
needed. It runs Devin's account-default model; set DEVIN_MODEL to pin one.
The setup overview now has two builtin ACP CLI rows (Devin, then Grok Build,
sorted by id), shifting the numbered rows below; the scripted-stdin ordering /
dispatch / openclaw tests are updated. Per-row catalog wiring is auto-covered by
the parametrized tests in test_acp_cli_harnesses.py.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): group the builtin `devin` harness under Harnesses, not Agents
This PR adds `devin` to the backend ACP CLI catalog, so a seeded Devin
agent carries `harness: "devin"` (a bare builtin id, not `acp:devin`).
The picker's harness/agent split calls isAcpHarnessAgent, which matches
`acp:*` or an id in ACP_CLI_HARNESS_IDS — a frontend mirror of
ACP_CLI_HARNESSES that still listed only `grok`. So the builtin Devin
fell into the "Agents" group instead of "Harnesses ▸ More".
Add `devin` to ACP_CLI_HARNESS_IDS so it groups with the harnesses,
beside Grok / OpenCode / Cursor, and extend the test.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(web): derive ACP harness identity from the server catalog, not a frontend list
Adding a builtin ACP harness took a frontend edit: the picker recognized ACP
agents via a hardcoded id set mirroring ACP_CLI_HARNESSES, and rendered their
name by capitalizing the agent slug. So a new row landed under "Agents" instead
of "Harnesses" until someone remembered the mirror, and even a known row showed
the wrong name — Grok Build as "Grok", a user's "My Devin Agent" as
"My-devin-agent".
Both facts already exist server-side and the frontend already fetches them: the
harness catalog reports `capabilities.integration_mode == "acp-subprocess"` for
builtin ACP rows AND user-configured `acp:<slug>` agents, plus a `label` (the
vendor's for a builtin, the user's own for a configured agent). The catalog
fetch just dropped both.
Read them: useAvailableAgents stamps `acpHarness` and the catalog label onto
each agent, isAcpHarnessAgent prefers that flag, and the id set stays only as a
fallback for servers that don't report capabilities. A new builtin ACP harness
is now one row in acp_cli_harnesses.py — the picker groups and names it with no
frontend change, which is what this PR's Devin row should have needed.
The catalog read is gated on the picker's own `enabled` so a disabled picker
still issues no request, and the label is applied only to ACP-family harnesses,
so a composed agent keeps its own name (Polly stays "Polly", not "Claude SDK").
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(server): seed configured ACP agents into the New Chat picker
The web New Chat picker lists AGENTS from GET /v1/agents, and native
harnesses appear only because _ensure_default_native_agents seeds a
<harness>-ui agent for each. Nothing seeded ACP agents, so a configured
acp:<slug> agent (Devin, ...) or an installed builtin ACP CLI harness
(grok) never showed in the picker on its own — the ACP sibling of the
`omni setup` discovery gap.
Seed a picker built-in per ACP harness set up on the server's host: one
per user-configured acp:<slug> agent (in config == set up, matching
harness_is_configured), and one per builtin ACP CLI harness whose binary
is on PATH. On a host with no ACP setup (the common remote-server case)
this seeds nothing.
Two things the naive version got wrong, fixed here:
- Name, not label. Agent names must be [a-zA-Z0-9_-]+, so a display label
like "Grok Build" / "Gemini CLI" fails spec validation at load ("agent
name ... must match ..."). Seed by the slug (agent.slug / the catalog
id); the web picker capitalizes it for display (devin -> "Devin").
- Grouping. GET /v1/agents already returns a `builtin` flag
(session-scope-NULL + deterministic id), but partitionAgentsByKind
grouped by a hardcoded name allowlist, so dynamically-seeded ACP agents
fell under "Custom agents". Group by the `builtin` flag, falling back to
the allowlist only for older servers — so seeded ACP agents sit with the
harnesses.
Purely additive: only adds picker rows, never touches native seeding; a
malformed acp: block is logged and skipped, never fatal to startup.
Verified against a real machine config (Devin + kilocode + grok all seed)
and with the web unit test for partitionAgentsByKind.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): group generic-ACP harness agents under "Harnesses", not "Agents"
The New Chat picker builds its "Harnesses" section from
agentList.filter(isNativeCodingAgent), so the ACP agents this PR seeds (Grok,
and configured acp:<slug> agents like Devin / Kilocode) fell through to the
"Agents" group beside Polly / Debby instead of sitting with the native CLIs.
Add isAcpHarnessAgent (harness `acp:*`, or a builtin ACP CLI id like `grok`)
and widen the picker's harness/agent split to include it, so these
harness-backed picks fold into "Harnesses > More" next to OpenCode / Cursor.
Grouping-only: selection is unchanged (both sections render through the same
renderEntry, whose onSelect launches by agent id), and ACP entries show no
readiness badge (they are not not-ready host entries). Composed built-ins
(Polly / Debby) still stay under "Agents".
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(web): show background tasks as a composer pill, not the working shimmer
Once a turn ends but background shells/sub-agents outlive it, the "Working…"
shimmer misreads as the agent still thinking. Route that state to a dedicated
BackgroundTaskPill above the composer instead: a shared isBackgroundTasksOnly
predicate gates both shimmer surfaces off and the pill on. A parked dialog
(blockedOn) still wins the shimmer, since it needs an action.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* e2e tests
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
---------
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
- Replace 4x set_labels + get_conversation pairs in _create_session_from_existing_agent
with in-memory conv.labels.update() — saves 4 round-trips per session creation
- _record_create_route_prompt: apply label in-memory instead of refetching the row
- _stamp_routing_decision_label caller: apply ROUTING_DECISION_LABEL_KEY in-memory
- _maybe_relaunch_managed_sandbox: replace host_store.is_online() (which calls get_host
internally) with host_is_live(host) using the already-fetched host object
- _maybe_wake_stale_resumable_managed_sandbox: same host_is_live fix
- Update test_concurrent_relaunch_messages_kick_a_single_launch to give its dead_host
SimpleNamespace the status/updated_at fields that host_is_live reads
Each query is slower on managed infra, so removing these redundant reads reduces
per-request latency on the hot session-creation and message-dispatch paths.
Closes OMNI-3243
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
On native Windows, `agent_env.BASE_ALLOW_EXACT` (the shared deny-by-default
env filter used by all harness executors) did not include SYSTEMROOT, COMSPEC,
USERPROFILE, or the other Windows-mandatory constants. Any harness CLI spawned
via `clean_agent_env` (codex, pi, claude-sdk, antigravity, …) died instantly
on spawn because Winsock/crypto cannot initialise without SYSTEMROOT — the
subprocess exited before reading stdin, causing the executor to await a
JSON-RPC response that never arrived and silently idle to the 600s watchdog.
The constant set already existed as `WINDOWS_ENV_PASSTHROUGH` in `_platform.py`
and was already wired into `os_env._DEFAULT_ENV_PASSTHROUGH` and
`connect._RUNNER_ENV_ALLOWLIST`. This commit adds it to `BASE_ALLOW_EXACT` so
every harness executor inherits it automatically, matching the pattern used
elsewhere.
Also fixes three related Windows issues surfaced in omnigent-ai/omnigent#4851:
- `PYTHONUTF8` was not forwarded through `_RUNNER_ENV_ALLOWLIST`, so the host
daemon / runner subprocess printed Unicode status chars (✓ ↑) on the Windows
ANSI code page (cp1252), raising `UnicodeEncodeError` and killing the host
tunnel in an infinite reconnect loop.
- `_session_create_validation.validate_existing_host_workspace` and
`_workspace_validation.validate_workspace` required `workspace.startswith("/")`,
rejecting every Windows drive-letter path (C:\…) from a connected Windows host.
Windows absolute paths matching `^[A-Za-z]:[/\\]` are now accepted.
- `harness_install._harness_cli_version_satisfies` returned `False` on
`packaging.version.InvalidVersion`, so pre-release versions like
`0.146.0-alpha.9.2` (newer than the declared floor) were reported as
too-old and the harness was refused at the version gate. The fix extracts
the leading X.Y.Z segment as a fallback for non-PEP-440 strings.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(bench): add CLI startup latency benchmark
Measures wall-clock time from omnigent claude --server invocation to the
Claude terminal being ready (signalled by 'Claude terminal ready.' spinner
message, emitted just before tmux attach).
Unlike the HTTP/API benchmarks in run.py, this drives the real CLI binary
end-to-end against a remote server — auth, daemon tunnel, session create,
runner launch, terminal boot — via pexpect.
Usage:
uv run --no-sync dev/benchmarks/omnigent/cli_startup.py
uv run --no-sync dev/benchmarks/omnigent/cli_startup.py --also-isaac-omni --runs 10
uv run --no-sync dev/benchmarks/omnigent/cli_startup.py --output startup.json
uv run --no-sync dev/benchmarks/omnigent/cli_startup.py --max-p50-ms 12000
JSON output is compatible with the existing benchmark schema.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): add cli-startup job to benchmark workflow
Adds a new 'CLI startup latency' job that runs cli_startup.py against
the ai-devtools managed workspace (OMNIGENT_REMOTE_AUTH_TOKEN secret).
- Runs on nightly schedule (when secret is configured) and on
workflow_dispatch with cli_startup_runs input (default 5, 0 = skip)
- Skips gracefully when OMNIGENT_REMOTE_AUTH_TOKEN secret is absent
- Uploads benchmark-results-cli-startup-{run_id}.json as an artifact
for the Databricks trend dashboard (same schema as the HTTP benchmarks)
- Renders a job summary table via report_markdown.py
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(bench): align cli_startup with existing journey schema
- Use RunResult/aggregate/print_results/check_thresholds/build_report
from the existing framework instead of custom stats/output code
- Each run is now a RunResult with all latency samples (matching the
HTTP/API journey shape), not one run-per-sample
- Journey names are cli_startup and isaac_omni (snake_case, no spaces)
- Output table uses the same renderer as run.py
- Add cli_startup_runs dispatch input and cli-startup job to benchmark.yml
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(bench): move cli_startup into journeys.py; use local bench server
The cli_startup journey now lives in journeys.py alongside the other
journeys, using env.base_url (the local bench server) instead of a
remote Databricks URL. This aligns it with the existing pattern:
needs_host=True boots the host daemon, and omnigent claude --server
<local-url> connects to it for the full startup sequence.
cli_startup.py becomes a thin shim that calls run.py --journeys cli_startup.
benchmark.yml cli-startup job now uses run.py directly — no
OMNIGENT_REMOTE_AUTH_TOKEN secret needed, just pexpect + claude CLI.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): fold claude CLI install into Install dependencies step
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): merge cli_startup into existing benchmark job (sqlite leg only)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* remove cli_startup.py shim — use run.py --journeys cli_startup directly
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): run cli_startup on all matrix backends
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): install pexpect+claude before Run benchmark so cli_startup does not skip
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): fix policy_evaluate setup (POST /v1/agents → /v1/sessions bundle); add needs_runner to cli_startup
- policy_evaluate setup was calling POST /v1/agents which is GET-only.
Fix: use POST /v1/sessions multipart bundle upload (same as ensure_agent),
with executor fields added to pass spec validation, and read session_id
from the correct response key.
- cli_startup: add needs_runner=True so the test_runner_journeys_are_capped
invariant passes (needs_host implies needs_runner in BenchEnvironment but
not on the Journey dataclass itself).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): add pexpect+claude install to benchmark-pr.yml
cli_startup is in ALL_JOURNEYS so it runs in the benchmark-pr regression
check too. Without pexpect and claude installed, every iteration fails
with RuntimeError.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): replace test fixture function ref in policy_evaluate with self-contained one
tests.runtime.policies.conftest._always_allow is a test fixture that may
not be importable in the server subprocess's PYTHONPATH in CI, causing
HTTP 500 on every evaluate call. Replace with _bench_policy_allow defined
directly in journeys.py, which is always importable since dev/ is on
PYTHONPATH in the benchmark environment.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): gate cli_startup on OMNIGENT_BENCH_SERVER; skip gracefully when not set
cli_startup conflicts with the bench environment's host daemon when run
against the local bench server — omnigent claude spawns its own daemon
which hits a 'host on another replica' error. Gate on OMNIGENT_BENCH_SERVER
env var instead: skip with a clear RuntimeError when unset, use the remote
server when set.
- Remove needs_runner/needs_host (no local server contact)
- Reduce max_iterations from 5 to 3 (each is ~10s)
- Set OMNIGENT_BENCH_SERVER in benchmark.yml and benchmark-pr.yml
- Relax test_runner_journeys_are_capped to allow non-runner journeys to cap
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(bench): remove hardcoded OMNIGENT_BENCH_SERVER from workflows
cli_startup skips gracefully in CI (no OMNIGENT_BENCH_SERVER set).
Run it manually: OMNIGENT_BENCH_SERVER=<url> uv run --no-sync dev/benchmarks/omnigent/run.py --journeys cli_startup
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): run cli_startup against local bench server; drop OMNIGENT_BENCH_SERVER
The daemon conflict was caused by needs_host=True booting a bench daemon
alongside the CLI's own daemon. With needs_host=False the bench environment
starts only the server; omnigent claude spawns its own daemon freely — no
conflict.
Result: 5.3s local vs 11s remote. CI runs it as part of the default suite
with no remote credentials needed.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): use omnigent polly instead of omnigent claude for cli_startup
claude-native requires the external claude CLI binary which:
- Takes too long to boot on CI (90s timeout → job gets stuck)
- Requires npm install of @anthropic-ai/claude-code
polly (omnigent run with the bundled openai-agents harness) exercises the
same startup path (daemon, session create, runner launch, runner connect)
without any external binary dependency. Signal: 'Launching your agent'
with a 30s timeout instead of 90s.
Remove @anthropic-ai/claude-code install from both benchmark workflows.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): move cli_startup to OPT_IN_JOURNEYS; exclude from default run
cli_startup against the local bench server hangs in CI — the polly runner
can't complete its startup within 30s, burning 19 min (39 attempts × 30s
including warmup) before failing.
Move it to OPT_IN_JOURNEYS: excluded from the default set, must be run
explicitly via --journeys cli_startup. resolve_journeys() looks in both
registries so it still works when named. Remove pexpect install from CI
workflows since it's no longer needed for the default benchmark run.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): cli_startup back in ALL_JOURNEYS; add skip_warmup flag; 60s timeout
- Move cli_startup back to ALL_JOURNEYS (not needs_host; spawns its own daemon)
- Add Journey.skip_warmup: when True, run_latency skips the warmup phase
regardless of --warmup. Avoids 10x60s = 10min of wasted warmup hangs.
- Increase timeout from 30s to 60s (CI runner is slower than local Mac)
- Restore pexpect install in both benchmark workflows
With skip_warmup=True and max_iterations=3: 3 runs x 3 = 9 iterations max,
no warmup hangs. Worst case: 9 x 60s = 9min if all timeout (shouldn't happen).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* debug(bench): include RuntimeError message in failure breakdown for CI visibility
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): stop stale daemons before each cli_startup iteration
A leftover host daemon from the previous iteration causes the next
omnigent polly to fail with 'runner tunnel rejection' or 'host is on
another replica'. Run omnigent stop before spawning polly to ensure
a clean slate each time.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(bench): move omnigent stop to prepare hook so it's outside the latency timer
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
## Related issue
[OMNI-3489](https://linear.app/omnigent/issue/OMNI-3489/honor-omnidev-state-and-config-directories-in-omnigent-cli-paths)
## Summary
- Prevent `omnidev omnigent …` commands from leaking auth tokens, session logs, host daemon records, and native harness launch state into the developer's real `~/.omnigent` directory.
- Make runtime state honor `OMNIGENT_DATA_DIR` while configuration independently honors `OMNIGENT_CONFIG_HOME`; harness-specific native-state overrides still take precedence.
- Keep the real `HOME` and `XDG_*` environment intact so harness credentials and caches remain available, and update REPL E2E setup to seed its theme in the effective config without clobbering mock auth.
**ELI5:** omnidev already gives each development pod its own labeled storage boxes, but some Omnigent code still put files in the user's shared box. Those paths now use the pod's boxes without moving the user's home directory.
```text
omnidev omnigent
|
+-- OMNIGENT_DATA_DIR ------> tokens, logs, host/native state
+-- OMNIGENT_CONFIG_HOME ---> config.yaml
+-- HOME / XDG_* ------------> unchanged credentials and caches
```
## Test Plan
- `uv run --frozen pytest tests/frontends/sdk/test_user_config.py`
- `uv run --frozen pytest tests/test_native_state_legacy_dirs.py`
- `uv run --frozen pytest tests/host/test_cli_host.py::test_host_pid_path_honors_data_dir_at_import`
- `uv run --frozen pytest tests/e2e/omnigent/test_pexpect_harness.py`
- `uv run --frozen pytest tests/e2e/omnigent/test_repl_smoke.py::test_repl_smoke_single_prompt`
- `cargo test --manifest-path dev/omnidev/Cargo.toml omnigent_cmd::tests`
- `uv run --frozen ruff check omnigent/claude_native_state.py omnigent/cli.py omnigent/cli_auth.py omnigent/codex_native_state.py omnigent/opencode_native_state.py omnigent/repl/_session_log.py sdks/ui/omnigent_ui_sdk/terminal/_config.py tests/frontends/sdk/test_user_config.py tests/host/test_cli_host.py tests/test_native_state_legacy_dirs.py tests/e2e/omnigent/_pexpect_harness.py tests/e2e/omnigent/test_pexpect_harness.py`
- `cargo fmt --manifest-path dev/omnidev/Cargo.toml --check`
## Demo
N/A — non-visual CLI state-isolation fix.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [x] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Regression tests cover pod environment wiring, data/config override precedence, HOME fallbacks, host pidfile placement, native harness state roots, and REPL startup with an isolated config home.
## Changelog
`omnidev omnigent` commands now keep runtime state and configuration inside their development pod.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
Set build.mac.target to build both x64 and arm64 for dmg + zip so the
macOS desktop build stops shipping only the build host's architecture.
mac.artifactName already templates ${arch}, so the two arches produce
distinct files. Config only — electron-builder reads mac.target the same
way for the manual signed release build (pnpm run build:mac:release).
Closes#842
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
This dispatch-only workflow only produced unsigned, throwaway desktop
installers as workflow artifacts — it never published a release. Nothing
depends on it: it is workflow_dispatch-only (not a reusable workflow), no
other workflow or action references it, and the secure release repo builds
Windows + Linux itself (it merely models this workflow's steps). Rather than
maintain a second, drift-prone desktop-build definition, remove it. The
macOS multi-arch change lives independently in web/electron/package.json.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
## Related issue
Follow-up to #4673.
## Summary
- Add a typed, default-off release-feature registry driven by one comma-separated `OMNIGENT_FEATURES` environment variable, with strict validation and lifecycle metadata.
- Gate the web Usage route/navigation and page-only report enrichment while preserving the existing `GET /v1/usage` CLI API.
- Migrate web-driven harness installation to the same immutable startup snapshot and wire rollout configuration across Docker, Kubernetes, Render, Railway, and Databricks.
ELI5: the server reads one list of enabled features when it starts, enforces that same list on backend routes, and tells the web app which controls and pages to show.
```text
OMNIGENT_FEATURES
|
v
FeatureFlags snapshot
/ \
backend gates GET /v1/info
|
v
frontend gates
```
## Test Plan
- `uv run pytest tests/server/test_feature_flags.py tests/host/test_local_server.py tests/server/integration/test_utility_endpoints.py tests/server/integration/test_hosts_install_harness.py tests/server/integration/test_hosts_store_credential.py tests/server/routes/test_usage_report.py tests/server/test_openapi_drift.py -q`
- `cd web && pnpm vitest run src/lib/capabilities.test.ts src/lib/harnessSetup.test.ts src/App.test.tsx src/shell/Sidebar.test.tsx`
- `uv run pytest tests/e2e_ui/sessions/test_usage_page_feature.py -q`
- `uv run python scripts/dump_openapi.py --check`
- `pre-commit run --files <changed files>`
- Verified default-off and enabled Usage route/sidebar behavior, strict unknown-feature rejection, legacy CLI usage compatibility, and harness route enforcement.
## Demo
- Default off: the updated visual baselines show the original sidebar without the Usage row.
- Enabled Usage page: https://github.com/user-attachments/assets/8385d4f0-47ad-430f-bf2c-06c35af6c499
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [x] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [x] Integration tests added / updated
- [x] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Manually reviewed the default-off visual output and verified that the Usage route is absent while the capability is disabled. Targeted backend and frontend tests cover both flag states, capability parsing, startup snapshots, and harness enforcement.
## Changelog
Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A — test-only reliability fix.
## Summary
- Prevent the quiescence backoff regression test from exhausting an event-loop iteration budget while its polls are still completing in worker threads.
- Signal the async test when the target poll count is reached and always cancel its mirror task during cleanup.
## Test Plan
- `uv run pytest tests/test_antigravity_native_reader.py::test_the_quiescence_recheck_backs_off_after_agy_vetoes_a_close -q`
- `uv run ruff check tests/test_antigravity_native_reader.py`
## Demo
N/A — non-visual test-only change.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [x] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
The updated unit test exercises the existing quiescence recheck backoff behavior with deterministic cross-thread synchronization.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
google-antigravity ships proto files compiled against protobuf 7.x
(gencode version 7.35.x). With the prior `protobuf>=6,<7` core pin the
runtime was always 6.x, causing:
Detected incompatible Protobuf Gencode/Runtime versions when loading
google/antigravity/proto/localharness.proto: gencode 7.35.0 runtime
6.33.6. Runtime version cannot be older than the linked gencode version.
Fixes#4774.
Changes:
- Widen core `protobuf` constraint from `>=6,<7` to `>=6,<8` so the
resolver can pick 7.x when needed.
- Pin `protobuf>=7,<8` in the `antigravity` extra so installing
`omnigent[antigravity]` always selects a 7.x runtime; the protobuf
cross-version guarantee lets a 7.x runtime load our 6.x gencode.
- Declare `[tool.uv] conflicts` for extra/group pairs that are mutually
exclusive (antigravity vs cwsandbox/modal; lint vs cwsandbox/modal)
so uv can resolve them in independent forks without a lockfile error.
- Bump `grpcio-tools` floor to `>=1.83` (first release that bundles
libprotoc 35.1 / protobuf 7.x gencode) and regenerate
`omnigent/api/routing/v1/routing_pb2.py` so the `routing-pb2-fresh`
pre-commit hook continues to pass.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(runner): auto-assign structured names to subagents
Subagents are now automatically assigned meaningful structured names
(e.g. "researcher-1", "coder-2") at spawn time instead of relying on
LLM-chosen titles. A background display-name generator also produces
human-readable task-derived labels (e.g. "Investigate auth token
refresh") that the UI prefers when available.
The LLM's `title` argument to sys_session_send becomes optional — it
is stored as a hint label for display-name generation but is no longer
the spawn-or-continue key. The structured name is returned in the
response handle; the LLM uses it (or session_id) to continue sessions.
Changes span the full stack:
- Entity/DB: new display_name column on conversation metadata
- Runner: per-parent ordinal counter with restart recovery
- Tool dispatch: auto-generate structured names, make title optional
- Server: expose display_name on ChildSessionSummary, schedule
background display-name generation for child sessions
- Web UI: prefer display_name in graph/panel labels
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* feat(web): let bulk session delete clean up worktree branches
Selecting multiple sessions and hitting delete previously showed a dead-end
warning ("Branches are not cleaned up. Use single-session delete for branch
surgery."). Since the bulk delete already fires N independent DELETE requests,
we can offer the same per-branch cleanup the single-session flow has.
The confirm modal now lists the local git branch of each selected worktree
session with a checkbox (default unchecked, since branch deletion is
irreversible) plus a Select all / Clear all toggle. Each ticked branch rides
along as ?delete_branch=true on that session's own DELETE. Sessions without a
worktree contribute no checkbox, and the list is hidden entirely when nothing
in the selection has a branch.
No server change: DELETE /v1/sessions/{id}?delete_branch=true already applies
per session. git_branch is already on each list-sourced conversation, so no
extra fetch is needed.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): widen bulk-delete modal, stack Select all below warning
The branch checkbox list rendered in the default narrow dialog (sm:max-w-sm),
and the Select all toggle sat inline with the warning text, compressing it.
Widen the modal to sm:max-w-lg and move the toggle onto its own line below the
warning.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): outline the Select all toggle, loosen branch-list spacing
The ghost-variant toggle had no border, so it read as oddly indented text
rather than a button — switch it to the outline variant. Bump the checkbox
list from gap-1 to gap-3 (and raise the scroll cap to max-h-56) so the
two-line branch/title rows no longer feel cramped.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* feat(web): table-style branch picker with tri-state header checkbox
Experiment: replace the list + "Select all" button with a table (Branch /
Session columns). The button becomes a header checkbox that reflects the row
selection — unchecked when none are ticked, indeterminate ([-]) for a partial
selection, checked when all are — and toggling it selects or clears every row.
Reverting to the list layout is a matter of resetting to the prior commit.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* test(web): mock useLeaveSession in bulk-delete-branch test
main added useLeaveSession to ConversationRow (#4571); the new bulk-delete
branch test mocks @/hooks/useConversations wholesale, so it must export it too.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
---------
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
## Related issue
N/A
## Summary
- Fix omnidev's Vite command after the pnpm migration: pnpm forwards script arguments directly, so the retained npm-style `--` caused Vite to ignore the configured host, port, and strict-port flag.
- Remove the separator, assert the complete forwarded argument list in the unit test, and correct the omnidev documentation.
## Test Plan
- `cargo test --manifest-path dev/omnidev/Cargo.toml process::tests::vite_forwards_configured_host_and_port_but_backend_url_stays_loopback`
- `cargo fmt --manifest-path dev/omnidev/Cargo.toml -- --check`
- `git diff --check`
- Manually ran `pnpm run dev --host 127.0.0.1 --port 43220 --strictPort` from `web/` and confirmed Vite bound to port 43220.
## Demo
N/A — this fixes local development process arguments and has no visual UI change.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
The unit test now verifies pnpm receives the configured Vite host and port without an npm-style separator. A direct pnpm/Vite run confirmed the corrected command binds to the requested port.
## Changelog
`omnidev --vite-port` once again starts the frontend on the requested port.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A
## Summary
The published `dev` extra mixed repository workflows with installable Omnigent capabilities. Move contributor-only dependencies to PEP 735 groups so package extras describe product functionality and CI installs only the workflow dependencies it executes.
- Replace the `dev` extra with local-only `lint`, `test`, and aggregate `dev` groups; configure no default groups so plain `uv sync` matches the published base package.
- Remove the retired mypy dependency/configuration, `types-PyYAML`, the orphaned `pathspec` declaration, and the duplicate `filelock` declaration.
- Migrate workflows, actions, contributor commands, tests, and development skills from `--extra dev` to the smallest required group, or no group for application/benchmark jobs.
- Compose Pyrefly's lint environment from the `lint` group plus the existing `hindsight`, `nimble`, `s3`, and `tracing` capability extras. Remove the OpenTelemetry missing-import configuration and Nimble's inline missing-import suppression so real package types remain checked.
- Update OpenShell, e2e, browser-test, Slack, and implementation-plan commands to compose capability extras with repository groups explicitly. Document why read-only/tools-less agent workflows intentionally keep runtime-only environments.
- Avoid `--all-extras`: it resolves but selects 240 product packages, including unrelated large/native integrations. Keep capability ownership explicit instead.
ELI5: product features remain extras users can install; lint and test toolboxes become private repository groups that never appear in the wheel.
```text
published wheel: base + capability extras
repository: lint group | test group | dev = lint + test
CI lint: lint + explicitly type-checked capability extras
```
## Test Plan
- `uv lock && just normalize-locks`
- Built the wheel and verified its metadata contains no `dev` extra or lint/test dependencies.
- Verified a fresh base environment imports Omnigent, excludes lint/test/pathspec packages, and imports each release benchmark script.
- `uv run --isolated --frozen --group lint --extra hindsight --extra nimble --extra s3 --extra tracing pre-commit run pyrefly --all-files`
- `uv run --isolated --frozen --group lint python scripts/gen_routing_pb2.py --check`
- Verified isolated `test` and aggregate `dev` group membership independently.
- `uv run --isolated --frozen --group test pytest tests/tools/builtins/test_hindsight.py tests/tools/builtins/test_nimble_research.py tests/stores/test_s3_artifact_store.py tests/db/test_d1_fts_dialect.py -q` (172 passed)
- `uv run --isolated --frozen --group test --extra tracing pytest tests/runtime/test_telemetry.py tests/inner/test_tracing_genai_semconv.py -q` (69 passed)
- `uv run --isolated --frozen --extra openshell --group test pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py -q` (259 passed)
- Verified load-test modules import with only `loadtest` and `agents-sdk` extras.
- Ran the exact locked lint sync against PyPI and Pyrefly passed.
- Rebased onto current `origin/main`; migrated the newly added compatibility-smoke test actions and host benchmark workflow.
- Surveyed all tracked uv install/run commands and removed every remaining published-`dev`/implicit-tooling command. Verified the documented e2e and Slack environments and collected the Kimi/live-DDG tests in fresh group-selected environments.
- `uv run --frozen pre-commit run --all-files`
## Demo
N/A — dependency metadata and CI configuration only.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Fresh isolated environments validated the base, lint, test, aggregate dev, tracing-test, and load-test dependency boundaries. Focused tests prove retained optional clients are genuine test runtimes, while wheel inspection proves repository groups are not published.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* perf(terminal): attach web terminals over loopback when the runner is local
Every keystroke in the web terminal round-trips the browser to the server
and back down the runner tunnel, so a WAN-hosted server costs 2x RTT
(~250ms echo against a Databricks App) versus <10ms locally.
When the runner is on the same machine as the browser, that detour is
avoidable. The runner now starts a loopback-only listener that serves the
existing attach handler and adverts its port plus a per-boot token in the
tunnel hello. The server surfaces the resulting ws://127.0.0.1 URL to
session owners only, and the browser connects over the relay first, then
hot-swaps to the direct socket once Chrome's local-network permission is
granted. Everything degrades silently to the relay: no advert, a
non-owner caller, a blocked handshake, or Safari all keep today's path.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the terminal loopback attach and its relay fallback
The E2E UI gate flagged that the direct-attach change alters browser
terminal connection behavior with only unit coverage. Add a Playwright
test for both halves of the contract, both observable in the harness
(server, runner, and browser share a box): the terminal ends up on the
runner's loopback socket, and it still connects over the relay when that
socket is unreachable — the path every remote browser takes.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style(web): satisfy prettier in TerminalView
The rebase left the buildAttachUrl call expanded across lines; prettier
collapses it to one.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style(web): satisfy oxlint on the direct-attach terminal path
Use a function-signature property for the Permissions API shim and a plain
throwing function instead of a class for the SecurityError stub, so the
--deny-warnings lint stays clean.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): surface direct-attach listener failures instead of swallowing them
The listener's startup and shutdown paths wrapped `await task` in
`contextlib.suppress(..., Exception)`, so a uvicorn server that died on its
own was discarded silently. Read the outcome back off the task via
`asyncio.wait` instead: the task's own failure is never re-raised into the
runner, but it is now logged, and both waits are time-bounded.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): retire the outgoing terminal session when the direct advert lands
The runner's loopback advert reaches the client on a terminals refetch, after
the terminal has already dialed. Adding directAttachUrl to the attach ref's
deps made that prop change re-run the ref for the same mount node, and React 18
neither remounts the node nor runs the ref's cleanup — so xterm stacked a
second instance inside one container (two helper textareas, two renderers, two
live bridges) and the superseded upgrade watcher could re-dial over the session
that replaced it.
Each attach now retires its predecessor: abort the outgoing upgrade probe,
dispose the session, clear the node, and stamp a generation so in-flight async
work from a superseded attach bails out.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): name the harness on native approval cards
Native-harness bridges stamp a synthetic policy_name ("claude_native_permission", "codex_native_command_approval", ...) and a constant phase ("pre_tool_use") on the elicitations they publish. The approval card rendered both verbatim, so the chat header leaked internal provenance ids that read as debug output.
Map the known native prefixes to their product name (Claude Code, Codex, Cursor, Antigravity) and hide the constant phase chip for them. User-authored policy names and phases still render verbatim, since those identify which policy asked.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): cover the native approval card's harness label
Drives a synthetic claude-native permission-request hook against a seeded session (no LLM, no native CLI) and asserts the pending card and the responded pill name "Claude Code" without leaking the claude_native_permission stamp or the constant pre_tool_use phase. Verified to fail against the pre-fix component.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): point the native-policy label table at where the ids originate
Polly review note: the prefix table encodes the harness bridges' naming
contract in a second place. Name the server modules that stamp the ids and
state the failure mode of a rename (raw id, never a wrong product name).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(web): resolve native approval labels from the vendor registry
The approval card carried its own four-entry prefix table mapping
`<vendor>_native_*` policy stamps to product names, duplicating display
strings that already live in NATIVE_CODING_AGENTS — and covering only
Claude, Codex, Cursor and Antigravity. Kiro, Goose, Qwen Code and Hermes
all stamp the same shape from their own hooks and still rendered the raw
id, as did the vendor-agnostic hook's `native_permission` fallback.
Derive the prefix table from the registry instead, via two shared
helpers, so every current vendor is covered and a new registry row needs
no second edit. A stamp with no known vendor is still recognized as
provenance, so the tag slot goes empty rather than printing the id.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): show answered question cards outside the "Worked for" fold
An AskUserQuestion or ExitPlanMode card arrives mid-turn, so the block
stream stamps it with the turn response id and the walker groups it with
the turn work — collapsing the user own answer behind the "Worked for"
disclosure, labelled as the agent work.
Split the bubble at such a card the way a user message splits it: the
work before it and the work after the answer each fold under their own
"Worked for", with the card standalone between them. Approval cards keep
folding into the turn they gated.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): drop the pending-tense ask from answered question cards
An answered question or plan card echoed the server gating message —
"Claude wants to call **AskUserQuestion**" — under a "Submitted" pill,
reading as if the ask were still outstanding when the user had just
answered it. The raw markdown asterisks showed through too, and the
answer line collided with the question mark ("prefer?: Red").
Drop the message on those cards, matching what the pending card already
does (purposeful content instead of the raw ask), and show the answer as
an emphasized value next to its muted question. Plain tool approvals
keep the message — there it is the only record of what was approved.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): rebuild answered question and plan cards on reload
Elicitations are never persisted, so refreshing a session dropped the
answered AskUserQuestion / ExitPlanMode card: the question came back as a
raw-JSON tool row folded into "Worked for" and the answer vanished
entirely. History hydration now reconstructs a responded card from the
persisted call plus its result — the same shape and transcript position
the live stream produces — pairing answers to questions verbatim so an
unescaped quote in a question can't garble them.
The store drops a live responded card when hydration rebuilds the same
question or plan, so the reconnect and window-rehydrate merges can't show
it twice.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The nightly benchmark already measures the host-bound session lifecycle
(session_cold_start = create -> host.launch_runner -> runner boot ->
first token), but the numbers lived only in JSON artifacts, and PRs
touching the host/runner/server never ran a benchmark at all
(benchmark-pr.yml is scoped to migrations + stores).
- Add .github/workflows/benchmark-host.yml: runs the host-session
journey set (cold start/restart, warm turn, first token, interrupt,
plus the common session actions) on PRs touching omnigent/host/**,
omnigent/runner/**, omnigent/server/**, or the harness, and on manual
dispatch. Informational -- no thresholds, so shared-runner noise can't
block a PR; gating stays with benchmark-pr.yml / release.yml.
- Add dev/benchmarks/omnigent/report_markdown.py: renders run.py JSON
reports as a journey x metric markdown matrix (mean/P50/P95/P99/rps +
run counts; skipped and all-failed journeys marked explicitly), with a
cross-report P50 matrix when given several reports.
- benchmark.yml: append the rendered matrix to $GITHUB_STEP_SUMMARY on
each backend leg so nightly numbers are readable on the run page.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): self-heal the chat stream when it dies silently
A half-open session stream (ingress reap without a close, laptop
sleep) left reader.read() blocked forever: the transcript froze while
the server kept publishing into a dead subscriber, and only a new tab
healed it. Guard the SSE body with a 45s byte-silence watchdog (the
server heartbeats every 15s), recycle stale stream attempts
immediately on tab-visible/network-online, and treat a non-SSE answer
on stream open (an auth ingress login page) as a failed open with
backoff instead of a zero-delay reconnect loop.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): cover silent-stall recovery of the chat stream
SIGSTOP the spawned server so the live stream goes byte-silent without
a close, then assert the stall guard declares it dead, a fresh /stream
open fires, and a real turn round-trips after SIGCONT.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): name the harness on native approval cards
Native-harness bridges stamp a synthetic policy_name ("claude_native_permission", "codex_native_command_approval", ...) and a constant phase ("pre_tool_use") on the elicitations they publish. The approval card rendered both verbatim, so the chat header leaked internal provenance ids that read as debug output.
Map the known native prefixes to their product name (Claude Code, Codex, Cursor, Antigravity) and hide the constant phase chip for them. User-authored policy names and phases still render verbatim, since those identify which policy asked.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): cover the native approval card's harness label
Drives a synthetic claude-native permission-request hook against a seeded session (no LLM, no native CLI) and asserts the pending card and the responded pill name "Claude Code" without leaking the claude_native_permission stamp or the constant pre_tool_use phase. Verified to fail against the pre-fix component.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): point the native-policy label table at where the ids originate
Polly review note: the prefix table encodes the harness bridges' naming
contract in a second place. Name the server modules that stamp the ids and
state the failure mode of a rename (raw id, never a wrong product name).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(web): resolve native approval labels from the vendor registry
The approval card carried its own four-entry prefix table mapping
`<vendor>_native_*` policy stamps to product names, duplicating display
strings that already live in NATIVE_CODING_AGENTS — and covering only
Claude, Codex, Cursor and Antigravity. Kiro, Goose, Qwen Code and Hermes
all stamp the same shape from their own hooks and still rendered the raw
id, as did the vendor-agnostic hook's `native_permission` fallback.
Derive the prefix table from the registry instead, via two shared
helpers, so every current vendor is covered and a new registry row needs
no second edit. A stamp with no known vendor is still recognized as
provenance, so the tag slot goes empty rather than printing the id.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): note the reserved <vendor>_native_ policy-name namespace
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): reclaim an occupied composer before injecting
A ctrl+r history search or hand-opened /model picker left covering the
input box from the embedded terminal swallowed injected web-UI
messages: the search's selected row renders the composer's prompt
glyph above a frame rule, so the readiness gate read it as a mounted
input box and the paste landed in the search filter — where the submit
Enter replays an old prompt. Both surfaces document Esc as their
dismissal, so injection (messages and slash commands) now closes them
with a hint-gated Escape and restores the empty composer before
typing. Escape is never sent blind: on the bare composer it interrupts
an in-flight turn. Shell mode stays undetected on purpose — its only
textual marker appears verbatim in the ? shortcuts panel while the
composer is fully usable.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(claude-native): note the accepted residual double-Escape window
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Creating a claude-native session from the web UI paid three serial,
avoidable costs between the create POST and the terminal appearing:
- The host tunnel handled inbound frames strictly serially, so every
create's launch frame queued behind that create's own background
host.model_options CLI exec (650-794ms measured), and workspace
validation's host.stat (2-9ms uncontended) queued behind landing-page
prefetches for up to 1.3s. Frames now run on their own tasks;
launch/stop keep arrival order via a lifecycle lock; a crashing
handler is contained instead of tearing down the tunnel.
- Terminal auto-create resolved ambient provider credentials (a ~0.7s
`claude auth status` subprocess on macOS) inside the user-visible
"Starting up..." window. The host now stamps the session's harness
into the runner env, and claude-native runners prewarm the detection
at boot, overlapping it with tunnel connect; the resolve consumes it
one-shot. Other harnesses pay nothing.
- The first launch of a daemon's life paid the runner zygote's one-time
import (~1.5s) inline. run() now pre-starts the zygote at daemon boot
via a helper shared with the launch path.
Same rig, pristine main vs this change: workspace validation
1508-5003ms -> 2-5ms; launch-frame queueing 1185-1712ms -> 8-17ms;
first-launch zygote import 1532ms -> 0ms; click->chat-page-open
1.6-2.0s -> 0.18-0.24s; click->"Starting up..." cleared 5.9-8.3s ->
3.6-4.9s.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* perf(claude-native): stop taxing every hook spawn with the eager package init
Claude Code blocks its TUI on command hooks — once per streamed text
chunk (MessageDisplay), per statusline refresh, and per tool call — and
every 'python -m omnigent.<hook>' subprocess re-ran omnigent/__init__,
which eagerly imported the datamodel/executor/model-catalog graph. The
deliberately stdlib-only hot-path hooks paid ~250 ms per spawn for
imports they never use, capping visible streaming at ~4 chunks/s.
The package init now re-exports lazily (PEP 562): the FIPS md5 patch
and legacy-env mirror stay eager, every public name resolves on first
attribute access (optional executors keep their import-failure->None
contract), and submodule attribute access still works. Hot-path hook
spawns drop to ~30 ms (~interpreter cost).
A native_hook_spawn benchmark journey spawns the MessageDisplay hook
exactly as Claude Code does and rides the release/nightly regression
comparison; fresh-interpreter import-graph guards in the display-hook
test suite pin what each hook entrypoint may import so the regression
cannot silently return.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* perf(claude-native): keep the hook's hot path off the bridge's heavy imports
The observer hook — Claude blocks on it at every prompt submit, tool
call, Stop, and task event — imported claude_native_bridge, whose
module-level tools/spec/pydantic imports cost ~450 ms of interpreter
startup, plus httpx and the policy machinery besides. Enter and every
tool call paid roughly a second of subprocess overhead per event even
after the package init went lazy.
The bridge now defers its tools graph to the one launch-path function
that builds MCP tools (_build_tools) and its bundle-skills parse to
the launch args builder; the hook imports httpx and the policy
machinery inside the subcommands that actually speak HTTP. Module
import cost: bridge 450 -> ~70 ms, hook 360 -> ~70 ms, and the hook's
fresh-interpreter import graph now contains no third-party modules at
all — the import guard pins the allowance at exactly that.
Tests that reached httpx or create_os_environment through the hook's
or bridge's module attributes now patch the owning modules directly.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* perf(claude-native): cache the ungoverned policy verdict at the relay
Sessions with no policies at all still paid a full server round trip
(~0.5-1.3s measured against a Databricks App) on every policy hook
event — twice per tool call plus every prompt submit — with the server
answering the same fast-path ALLOW each time. Typing during agentic
turns stuttered in the gaps; vanilla Claude pays nothing there.
The evaluate endpoint now stamps 'governed': false on its existing
no-policies fast path (any_policies_apply's False is session-scoped —
its only phase-scoped rule forces True), and the native-harness
loopback relay caches that verdict for 30s, answering hook events
instantly. A governed response of any kind drops the cache, a
sys_add_policy call through the relay's own /tool path clears it
before the policy lands, and expiry re-validates upstream — so
enforcement for governed sessions is untouched and the attach delay
for out-of-band policy edits is bounded at the TTL.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* perf(claude-native): keep blocking Claude hooks off Python and off the WAN
Claude blocks its TUI on every command hook, and three of them still
spawned a Python interpreter per event (~30ms floor, ~77ms under EDR):
MessageDisplay once per streamed chunk, statusLine per refresh, and
evaluate-policy twice per tool call — the last one also paying a
0.5-1.3s WAN round trip whenever its 30s ungoverned-cache window
lapsed.
- MessageDisplay: a /bin/sh one-liner appends the payload (newline-
stripped, so any valid JSON lands single-line) straight to
message_deltas.jsonl; the deltas reader already parses by key and
skips malformed lines.
- statusLine: the shim captures raw stdin to context_raw.json (atomic
rename) and chains the user's own status command; the forwarder
normalizes it into context.json on its poll loop
(sync_raw_status_context), so the Python normalizer leaves the
blocking path. The module entrypoint stays for older bridge dirs.
- evaluate-policy: hooks try a curl against the relay's new
/hook/claude/evaluate-policy endpoint (advertised via a
shell-sourceable tool_relay.env); the long-lived runner process owns
payload→EvaluationRequest mapping, retries, the ungoverned cache,
and verdict→hook-output shaping. When the relay is absent or
unreachable the same stdin replays into the Python hook, which keeps
the direct-server path and the phase-aware fail-closed contract —
exactly the pre-curl behavior.
- The relay starts at session create (runner app) instead of at the
first web-dispatched turn, so prompts typed directly in the TUI get
the curl fast path too; it comes up in the background, and hooks
that beat it use the Python fallback.
Typing during a live 25-tool-call turn against a Databricks App
measured 56.0ms median / 57.2ms p90 / 0 samples over 200ms, from
118ms median / 264ms p90 / 8 freezes before this branch.
Also pins the relay-close ownership test's trusted-parent monkeypatch
to tempfile.gettempdir() — the literal /tmp never contains the macOS
fixture root, so the test only passed on Linux.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* perf(onboarding): cache harness CLI version and login probes
Every readiness refresh on every host daemon execs vendor CLIs
(--version / auth status) whose answers change only when the binary is
swapped or a login flips; with a few dozen idle hosts that compounds
into a constant machine-wide subprocess storm (~116 spawns/min
observed) that competes with interactive terminals.
--version output is a pure function of the binary bytes, so successful
parses cache permanently against the binary's (path, mtime_ns, size)
signature; failures keep re-probing. Login verdicts can flip without a
binary change, so only positives cache, with a 120s TTL — negatives
always re-probe so the setup wizard sees a fresh login immediately, and
harness_logout invalidates its key so a successful logout is confirmed
live.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* revert(claude-native): drop the ungoverned-verdict relay cache
The cache required stamping 'governed': false on the evaluate
response so the relay could tell which ALLOWs were safe to reuse —
new response-field surface carried only by this optimization, which
we don't need right now. Remove the stamp and the relay cache
wholesale: every policy hook event consults the server again, the
relay's /policies/evaluate proxy is a plain pass-through, and the
evaluate response is byte-identical to its pre-branch shape. The
sh-shim/curl hook path (no interpreter spawns) is unchanged.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): name the harness on native approval cards
Native-harness bridges stamp a synthetic policy_name ("claude_native_permission", "codex_native_command_approval", ...) and a constant phase ("pre_tool_use") on the elicitations they publish. The approval card rendered both verbatim, so the chat header leaked internal provenance ids that read as debug output.
Map the known native prefixes to their product name (Claude Code, Codex, Cursor, Antigravity) and hide the constant phase chip for them. User-authored policy names and phases still render verbatim, since those identify which policy asked.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): cover the native approval card's harness label
Drives a synthetic claude-native permission-request hook against a seeded session (no LLM, no native CLI) and asserts the pending card and the responded pill name "Claude Code" without leaking the claude_native_permission stamp or the constant pre_tool_use phase. Verified to fail against the pre-fix component.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): point the native-policy label table at where the ids originate
Polly review note: the prefix table encodes the harness bridges' naming
contract in a second place. Name the server modules that stamp the ids and
state the failure mode of a rename (raw id, never a wrong product name).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(web): resolve native approval labels from the vendor registry
The approval card carried its own four-entry prefix table mapping
`<vendor>_native_*` policy stamps to product names, duplicating display
strings that already live in NATIVE_CODING_AGENTS — and covering only
Claude, Codex, Cursor and Antigravity. Kiro, Goose, Qwen Code and Hermes
all stamp the same shape from their own hooks and still rendered the raw
id, as did the vendor-agnostic hook's `native_permission` fallback.
Derive the prefix table from the registry instead, via two shared
helpers, so every current vendor is covered and a new registry row needs
no second edit. A stamp with no known vendor is still recognized as
provenance, so the tag slot goes empty rather than printing the id.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): note the reserved <vendor>_native_ policy-name namespace
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(e2e-ui): unroute the host-binding stub before closing its pages
test_presence_circles_track_other_viewers keeps failing with
`Browser.new_context: "Route.fetch: Target page, context or browser has
been closed ... while running route callback"`. It is the victim, not the
cause.
`_stub_host_binding` installs a route handler that does a real
`route.fetch()` on `GET /v1/sessions/{id}`, and `useSession` refetches
that URL for as long as the page is mounted, so one is almost always in
flight. Teardown closed the page and context without removing the
routes, so a callback still suspended inside `fetch()` raised once its
target was gone. Nothing awaits that error, so Playwright reports it on
the connection — where it lands on whatever call comes next, which is
the presence test's `browser.new_context()`.
Drop the routes with `unroute_all(behavior="ignoreErrors")` before
closing, as Playwright's own error message prescribes and as
test_host_badge and test_files_panel_header already do.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Opening the workspace directory browser focuses the header's first icon
button, and Radix opens a tooltip on any focus — so clicking the working
folder path immediately threw an "Up one level" label over the listing.
Gate the focus-driven open on :focus-visible so only a keyboard focus
ring (or a deliberate hover) reveals it.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The upper bound was pinned at <1.18.0 (added in #1550 with 'refuse 1.18+
until validated'). OpenCode 1.18.x has since shipped 17 releases, making the
gate reject every current upstream install.
The 1.17.x-shaped assumptions in the forwarder are already forward-compatible:
- part-based message events (message.updated / message.part.updated) are
unchanged in 1.18.x
- both permission.asked and permission.v2.asked are already handled
Changes:
- OPENCODE_MAX_VERSION_EXCLUSIVE: 1.18.0 -> 1.19.0
- npm install pin: opencode-ai@~1.17.7 -> opencode-ai@~1.18.0
- update tests and comments to match the new range
Fixes#4670
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
committedUserBlock fell back to Date.now() when no createdAtS was
provided. On the replayed-pending path — where toPending() intentionally
omits createdAtS — this caused consumed messages to briefly display the
consume time instead of no timestamp.
Use a conditional spread so clientCreatedAtS stays absent when no real
stamp exists. The rendering pipeline already handles undefined gracefully
by hiding the timestamp.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* test(e2e): add server/runner compatibility smoke tests
Guard both cross-version deployment orderings end-to-end:
- Config 1 (new server, old runner): test_new_server_old_runner_compat_smoke
runs unconditionally and verifies a turn completes when the runner is
pinned to an older build via OMNIGENT_COMPAT_RUNNER_PYTHON.
- Config 2 (new runner, old server): test_new_runner_old_server_compat_smoke
carries @pytest.mark.min_server_version("0.9.0") (the baseline for the
session-init envelope and /api/version probe) and verifies a turn
completes when the server is pinned via OMNIGENT_COMPAT_SERVER_PYTHON.
Both tests use the mock LLM server (already started by the e2e conftest)
with a uid-keyed model so parallel workers cannot share response queues.
Also adds docs/SERVER_VERSION_COMPAT_CI.md documenting the two env knobs,
the CWD isolation mechanism, the version cross-check tripwire, and guidance
for adding new compat guards in future.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci: wire compat smoke tests into CI
Add a compat-smoke-run composite action and two dedicated jobs in
server-compat.yml so the smoke tests run automatically:
- On every PR that touches the server↔runner contract surface
(session_init_protocol.py, runner/app.py, host/frames.py, transports/,
and the smoke test / compat helper files themselves).
- On every scheduled / manual run of Backwards-Compat.
Jobs:
compat-smoke-config1 — new server, old runner (latest stable tag)
compat-smoke-config2 — new runner, old server (latest stable tag)
Both run in ~5 min (no sharding; test file is single-node) and upload
server/runner logs as artifacts on failure.
The full pairwise matrix (backcompat-e2e / backcompat-integration) is
gated behind 'if: github.event_name != pull_request' so it only runs on
schedule/dispatch — the smoke jobs cover the PR case cheaply.
The compat-smoke-run composite action mirrors e2e-run's install steps
(Python, uv, tmux, bubblewrap, claude-code CLI) and the same
pinned-old-build logic (git worktree + isolated venv + COMPAT_*_PYTHON
env) so the smoke path and the full matrix path never drift.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* test(e2e_ui): add UI↔server compatibility smoke test + CI integration
The UI (SPA) always runs against the server that serves it, so the only
meaningful cross-version direction is: new SPA + new runner vs old server.
Changes:
tests/e2e_ui/test_server_compat_smoke.py
Single Playwright test (mirrors chat/test_smoke.py) that sends a message
and waits for an assistant reply. Carries @min_server_version("0.9.0")
(same baseline as the server/runner smoke) so it skips on genuinely old
servers that predate the /v1/info capabilities probe.
tests/e2e_ui/conftest.py
- Import server_executable, apply_server_env, compat_server_cwd from
tests/_helpers/compat.
- live_server fixture: replace hard-coded sys.executable with
server_executable(); replace the PYTHONPATH prepend with
apply_server_env() (drops PYTHONPATH in compat mode so the pinned old
venv resolves instead of being shadowed by the worktree); add
cwd=compat_server_cwd() to the server Popen call.
- Add session-scoped server_version fixture (reads GET /v1/info) and
_enforce_min_server_version autouse fixture, mirroring the e2e conftest.
.github/actions/compat-smoke-ui-run/action.yml
Composite action: Python + uv + pnpm + Playwright + bubblewrap + SPA build
+ pinned old server (git worktree + isolated venv) + run the smoke file.
Skips the Codex parity sidecar (Rust), which is not needed for the
openai-agents smoke.
.github/workflows/server-compat.yml
- compat-smoke-ui job using the new action, running on every PR that
touches the UI/server contract surface (added server/app.py, sse.ts,
sessionsApi.ts, capabilities.ts, e2e_ui conftest/smoke to paths filter).
- resolve-latest output consumed by all three smoke jobs in parallel.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(e2e_ui): point compat-pinned server at HEAD-built SPA via OMNIGENT_WEB_UI_DIST
The old server binary runs from its own venv (OMNIGENT_COMPAT_SERVER_PYTHON)
but the SPA is built from HEAD into omnigent/server/static/web-ui/. Without
OMNIGENT_WEB_UI_DIST the old binary serves its own stale (or absent) bundle,
returning 404 for SPA routes and causing the UI compat smoke test to fail with
'{"detail":"Not Found"}' on page load.
Setting OMNIGENT_WEB_UI_DIST=_BUILD_OUTPUT in the server env makes the old
binary serve the HEAD-built bundle, which is the correct compat scenario: old
server API + new SPA.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* test(compat): compat_smoke marker + backcompat-e2e-ui matrix
Instead of two dedicated single-file smoke tests, introduce a
compat_smoke pytest marker and tag 20 existing e2e/e2e_ui tests
so the compat PR gate runs a representative cross-component suite
in ~15 min rather than one minimal turn.
Marker (pyproject.toml):
compat_smoke — core server↔runner and UI↔server protocol boundary
tests. Selected by -m compat_smoke for the fast PR gate; also
collected by the full overnight backcompat matrix.
Tagged tests (10 e2e, 10 e2e_ui):
e2e: test_chat_local_starts_server_and_agent_responds,
test_chat_local_accepts_omnigent_yaml_file,
test_cancel_appends_history_marker_and_followup_sees_it,
test_cancel_mid_response_followup_succeeds,
test_full_fork_replays_whole_history,
test_usage_report_happy_path,
test_multi_turn_recovery_journey,
test_runner_does_not_500_old_server_emitting_waiting_status,
+ the two smoke tests added earlier
e2e_ui: test_send_message_renders_assistant_response,
test_multi_turn_recall_through_ui,
test_opening_a_session_fetches_history_once_and_then_stops,
test_stale_banner_on_runner_crash,
test_transient_stream_404_recovers_without_manual_reload,
test_bare_idle_clears_working_indicator,
test_session_rename_streams_to_open_tabs,
test_idle_sidebar_does_not_poll_sessions_list,
test_session_created_elsewhere_appears_via_push,
test_agent_info_version_footer_shows_server_version,
+ the UI compat smoke test added earlier
CI:
compat-smoke-run/action.yml: switch from single-file to
pytest tests/e2e/ -m compat_smoke.
compat-smoke-ui-run/action.yml: add full_suite/shard_id/num_shards
inputs; full_suite=true runs the complete e2e_ui/ suite with
sharding for the overnight matrix; false (default) runs -m compat_smoke.
backcompat-ui-matrix.sh: new script computing server-only cells
(runner is always main for UI compat; no runner axis).
server-compat.yml: add setup-ui + backcompat-e2e-ui jobs running
the full tests/e2e_ui/ suite against every old server tag, sharded
3 ways, schedule/dispatch only.
Cleanup:
Remove tests/e2e/test_server_runner_compat_smoke.py (covered by
compat_smoke marker on existing tests).
Remove docs/SERVER_VERSION_COMPAT_CI.md (superseded by inline
comments in the workflow and action files).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* test(compat): remove redundant UI compat smoke file and unused fixtures
test_server_compat_smoke.py is superseded by the compat_smoke marker on
test_smoke.py::test_send_message_renders_assistant_response, which tests
the same UI turn path. Remove the file and the server_version /
_enforce_min_server_version fixtures that existed solely to support its
@min_server_version guard.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci: broaden compat smoke PR trigger to any runner/server/web change
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci: add UI Config B (old SPA / new server) compat testing
Two UI compat configurations now tested:
Config A (existing): HEAD SPA + HEAD runner vs old server — guards
the common deploy ordering where server lags behind the frontend.
Config B (new): old SPA (built from release tag web/ source) vs HEAD
server — guards the cached-browser scenario where a user's browser
has an older bundle after a server upgrade.
Changes:
compat-smoke-ui-run/action.yml
- server_version is no longer required; add ui_version input.
- 'Build HEAD SPA' step skipped when ui_version is set.
- New 'Build old SPA from release tag' step: checks out the tag's
web/ source, runs pnpm build there, sets OMNIGENT_WEB_UI_DIST to
the old bundle so the HEAD server serves it.
- PR smoke jobs renamed to compat-smoke-ui-config-a/b.
backcompat-ui-matrix.sh
- Each release tag now emits 2 × num_shards cells: one config=A
(server=tag, ui='') and one config=B (server='', ui=tag).
server-compat.yml
- PR gate: compat-smoke-ui split into config-a and config-b jobs.
- Overnight matrix: backcompat-e2e-ui passes server_version or
ui_version per cell config.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(ci): locate old SPA build output by probing both known paths
v0.9.0 vite.config.ts writes to ../omnigent/server/static/web-ui
relative to web/ (not web/dist/). The cp failed with 'No such file
or directory'. Probe both locations and fail loud if neither exists.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(ci): copy old SPA into HEAD static dir so --ui-skip-build assertion passes
The built_spa fixture's _assert_service_worker_tombstone always checks
_BUILD_OUTPUT (omnigent/server/static/web-ui/ in the HEAD checkout).
In Config B --ui-skip-build was passed but that dir was empty, causing
10 collection errors. Copy the old built SPA there so the assertion
finds it; also set OMNIGENT_WEB_UI_DIST to the same path.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(ci): always build HEAD SPA; use OMNIGENT_WEB_UI_DIST to serve old bundle
The built_spa fixture's _assert_service_worker_tombstone checks HEAD's
omnigent/server/static/web-ui/ for PWA retirement invariants (no
manifest.webmanifest, tombstone sw.js). The v0.9.0 SPA still ships
manifest.webmanifest so copying it into _BUILD_OUTPUT triggers the
assertion.
Fix: always build the HEAD SPA (satisfying the assertion), then set
OMNIGENT_WEB_UI_DIST to the old bundle so the server serves it instead.
The HEAD build exists for the fixture; the server overrides which bundle
it mounts via the env var.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
``_fetch_pi_model_lists`` built its Pi ``models.json`` entries by hand as
``{"id", "input"}``, so the interactive ``omnigent pi`` launch never set
``contextWindow`` or ``maxTokens``. Pi defaults those to 128000 / 16384, which
silently caps the 1M-context gateway models at an eighth of their context and
their output at 16k — while the spawned harness path, which renders entries
through ``_pi_model_json_entry``, advertises the real limits. Same workspace,
same models, two different answers.
The workspace's model-service listing is authoritative for availability but
reports no limits; the MLflow catalog reports limits but not what a workspace
serves. The harness path already merges the two. Share that logic instead of
keeping a second, lossier copy of it:
- Move ``pi_model_json_entry``, ``pi_model_is_reasoning``,
``databricks_model_aliases`` and ``enrich_databricks_model_catalog`` into
``pi_model_compatibility``, which both paths already import, along with the
``PiModelEntry`` TypedDict (now carrying the two limit fields).
- Enrich and translate in ``_fetch_pi_model_lists`` through those helpers,
dropping its duplicated DeepSeek reasoning rule.
Enrichment is best-effort: a catalog outage logs and leaves the models listed
without limits, exactly as before. No behavior change on the harness path.
Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
Headers already support ${VAR} expansion (expand_env_vars), but the
url field on both directory MCP configs (tools/mcp/<name>.yaml) and
inline config.yaml entries did not — it was always coerced with a
plain str(). That meant a remote MCP server's endpoint had to be
either hardcoded in the YAML (bad for anything committed to version
control across environments) or worked around outside the parser.
Applies the same expand_env_vars treatment url already gets for
headers, in both _parse_http_mcp_server (directory configs) and
_parse_inline_mcp_servers (inline config.yaml). An unresolved
${VAR} in url now raises the same "Unresolved environment variable"
error headers already give, instead of silently connecting to a
literal ${VAR} string.
## Changelog
- [Bug fix] ${VAR} references in an MCP server's url field are now
expanded at parse time, matching headers — a directory or inline
MCP config can be committed to version control without hardcoding
the endpoint.
Signed-off-by: Shekhar Kadyan <shekharkadyan@gmail.com>
* fix(runner): recover from turn-context desync and fail-closed guardrails (#1026)
Recovers the runner from a cross-process turn-context desync that left a
conversation permanently wedged, and closes the fail-OPEN guardrail gaps and
generation-ownership races that desync exposed. Rebased onto current main; the
fail-closed policy default the original change carried has since landed upstream
(#1078), so this now reduces to logging on that path.
Root cause: after a mid-turn message buffer plus a harness disconnect, the
runner's and harness's turn-context lifecycles desynced. The cached inner-SDK
generation outlived its turn and later flushed queued tool_use as orphaned
callbacks ("no active turn context"); the verdict-delivery POST's transport
error was swallowed, parking the policy future for ~24h; and run_turn's
teardown could leave _active_turns stale so every later message buffered
forever.
Recovery:
- Identity compare-and-clear of the adapter's per-turn ctx slot so a stale
finally can't clobber a newer turn.
- Detached, bounded abnormal-exit interrupt of the abandoned inner generation;
the executor is detached synchronously so a fast continuation rebuilds a
fresh client. The whole cleanup (interrupt + close_session + close) runs under
ONE cumulative INTERRUPT_TIMEOUT_S so it can't outlast the subprocess shutdown
grace or stall the shutdown drain.
- Verdict-delivery acknowledgement: a verdict is delivered ONLY on a 2xx. A
dead-channel transport error, a read/write/pool timeout, a 3xx/4xx/5xx
response (httpx does not raise on non-2xx, so status is checked), OR an
unexpected exception all leave the harness future parked — each signals
recovery. Retry stays selective (transport/timeout/non-2xx retry once;
unexpected errors do not retry) but every unacknowledged outcome signals.
- Single ordered _resync_turn_state recovery entry wired to the dead-channel
signal and to a process-manager respawn hook (model/agent switch mid-turn).
- BaseException routed through a real finally floor in _run_turn_bg so
_active_turns is never left stale; the floor identity-compares against the
turn's own task.
- Publish-once token (_desync_terminalized) so a desync `failed` is the single
terminal status, never racing a competing idle from proxy_stream.
- Tier-1 self-heal watchdog after N consecutive orphan callbacks, covering
orphaned tool AND missing-context policy callbacks.
Generation ownership (a stale signal/teardown from an OLD response must never
touch a newer turn):
- _on_proxy_stream_end takes an owner_response_id; a proxy_stream terminal that
no longer matches the live response no-ops instead of clearing the newer
turn's slot, response id, and in-flight marker.
- The process-manager respawn hook fires only when the replaced process was
mid-response and carries its response id; the runner identity-matches it.
- _resync_turn_state carries owner_response_id centrally; a delayed/duplicate
verdict-delivery failure from an old response is ignored once a newer turn is
live. The delivery-failure callback binds the failing turn's response id.
- A desync-cancelled sub-agent is reported FAILED (matching the session's desync
`failed`), not a contradictory `cancelled`.
Host-tool force-reset hardening: an out-of-turn sys_os_* orphan forces the
Tier-1 reset on the first occurrence ONLY when the scaffold also has no live
turn (_active_turn_ctx is None), so a healthy turn winding down in the
_current_ctx/_active_turn_ctx clear-order window is not reset.
Fixes#1026
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ownership audit: swept every _active_turns / _live_response_id / clear_in_flight mutation site. All turn-start binds are gated by the single-active-turn invariant; delete_session is intentional user teardown; the desync pops and _on_proxy_stream_end are identity-guarded. Added the same identity guard to _drain_streaming_response's cancel handler (defense-in-depth: the drain runs inline in the owning turn task, so a stale pop cannot occur today, but the guard keeps the invariant explicit if the drain is ever moved to its own task).
Round-4 cleanup-path fixes (each addressed as a bug CLASS, not a line):
- In-flight marker leak (B1 class): popping _live_response_id severs the ownership link _on_proxy_stream_end keys on, so the stream's own terminal then skips clear_in_flight and the idle reaper skips the harness forever. Introduced _release_live_turn_markers() pairing the two, and used it at every live-process pop site (_on_proxy_stream_end, _resync_turn_state before any await, _drain_streaming_response cancel). delete_session terminates via release() so no leak.
- Starved reap (B2 class): a single cumulative wait_for let a slow/wedged interrupt_session consume the whole deadline, cancelling close_session/close — which do the real subprocess terminate/kill for subprocess-backed executors (ACP/codex), orphaning the child. Split into a bounded interrupt SLICE (_INTERRUPT_SLICE_S) plus a guaranteed reap budget (INTERRUPT_TIMEOUT_S), summing below the shutdown grace. Applied in _safe_interrupt; _maybe_resync_on_orphan already reap-only.
- Post-await ownership re-read (NB3 class): the buffer was re-read after teardown to decide terminal ownership, so a continuation that bound AND drained the buffer during the await got a desync failed published over it. Reserve ownership on the active-turn slot (conv in _active_turns) — a bound continuation means no failed publish.
- Sub-agent cancelled-vs-failed (NB4 class): mirrored the desync-aware failed terminal into _cancel_active_turn's fallback (was only in _on_proxy_stream_end).
Regression tests (toggle-verified fail-on-revert / pass-on-fix): B1 no-buffer real-marked-response clears the in-flight marker; B2 hung interrupt still reaps; NB3 continuation bound during the interrupt-forward await is not clobbered. Deferred (non-blocking, strictly safer than pre-#1078): synthesizing policy context for out-of-turn sys_call_async so background calls can ASK/DENY instead of fail-closed-deny — a background-dispatch feature outside this desync fix, better as its own change.
Round-5 fix (completed-task corpse; a bug CLASS I introduced in round 4):
_resync_turn_state's NB3 continuation check treated mere slot membership as a live continuation, but _cancel_inprocess_turn returned early on a DONE task without removing it — so a completed generation lingered in _active_turns, was mistaken for a healthy continuation, suppressed the terminal, and wedged every later message behind it (post_session_events buffer gate) = the original #1026 wedge.
CLASS = 'a done Task in _active_turns is a corpse, not liveness'. Fixed both leavers (_cancel_inprocess_turn AND _cancel_active_turn now compare-and-remove a done task + clear its markers) and made _resync_turn_state's ownership check require a DISTINCT LIVE occupant: snapshot the original slot, sweep any corpse (identity-equal to the original, or any done Task) before deciding, and count only a different live occupant as a continuation. With both leavers fixed no done task is ever left, so the membership-based liveness checks (buffer gate, _check_and_start_next_turn) are safe by invariant.
Regression test (toggle-verified fail-on-full-round-4-revert): a completed task left in the slot is removed, its in-flight marker cleared, and a single terminal desync failed published — the reviewer's exact reproduced case.
Also made the out-of-turn host-tool test honest: it asserts BOUNDED churn (no 88x pile-up), NOT recovery-to-successful-execution — healthy out-of-turn sys_call_async execution needs the deferred policy-context synthesis (follow-up, out of scope).
Self-audit hardening (pre-empting the next round on the corpse/ownership logic that generated the last two regressions): consolidated all corpse removal into one _sweep_dead_turn_slot(conv, occupant) helper — identity-guarded pop + _release_live_turn_markers + _interrupted_sessions.discard — used at all three sweep sites (_cancel_inprocess_turn, _cancel_active_turn, _resync_turn_state). This closes a latent same-class bug: a live turn cancel-forwarded by _cancel_inprocess_turn can COMPLETE during the _forward_harness_interrupt await and arrive DONE at _cancel_active_turn's sweep; its _interrupted_sessions token (NOT cleared at the next _run_turn_bg start, unlike _desynced_sessions/_desync_terminalized) would otherwise taint the next turn's _on_proxy_stream_end into a spurious idle/cancelled. Regression test test_resync_clears_interrupt_token_when_task_completes_during_teardown (toggle-verified). Full set 329 pass/1 skip; mypy net-zero (131).
Round-6 fix (stream-mode None-sentinel conflation; a flaw in round-5's own corpse check): both the old wedged stream=true turn and a freshly bound stream=true continuation park _active_turns[conv]=None, so the round-5 identity check _slot_now is _original_slot mistook the NEW turn for the old corpse — swept its slot + resp_new marker and published desync failed over it. Root fix: after round-5's leaver fixes, teardown ALWAYS removes the wedged generation (stream-sentinel pop, or _cancel_inprocess_turn / _cancel_active_turn sweeping even a corpse), so any occupant present AFTER teardown is a distinct continuation — decide on that removal invariant, NOT on comparing the slot value (None==None for all stream turns). Dropped the _original_slot snapshot + identity corpse-sweep from _resync_turn_state; kept the done-Task exclusion defensively. The corpse sweep still runs where the wedged generation is actually removed (_cancel_inprocess_turn / _cancel_active_turn). Regression test test_resync_does_not_clobber_stream_continuation_reusing_none_sentinel (a None-sentinel continuation binds during the interrupt await) toggle-verified. Also trimmed production comments to the generation-ownership invariant per review + project comment guidance. Deferred sys_call_async policy-context synthesis needs a tracking issue filed (out of scope). Full set 330 pass/1 skip; mypy 131.
Round-7 fix (generation epoch): a replacement turn that STARTS AND FINISHES during the interrupt await left an empty slot, so the round-6 post-teardown slot check missed it entirely and recovery published desync failed over it; its terminal was also swallowed by the conversation-wide suppression token. Replaced membership-as-liveness with a monotonic per-conversation turn-bind epoch (_turn_bind_epoch, bumped by _begin_turn_slot at every turn-start bind, including continuations that later complete). Recovery captures the entry epoch and treats ANY epoch advance as a continuation — detectable even after the replacement finished. Scoped the publish-once token to that epoch (_desync_terminalized is now conv->epoch): a competing terminal suppresses its own idle only while the epoch matches, so a newer generation's terminal is never swallowed. Regression test test_resync_does_not_clobber_replacement_that_finished_during_interrupt (replacement runs to completion during the interrupt) toggle-verified. Out-of-turn sys_call_async policy-context propagation is intentionally out of scope and tracked as a follow-up in omnigent-ai/omnigent#3233. Full set 300 pass/1 skip + 74 pass; mypy 131.
Round-7 follow-up (non-blocking): delete_session now clears ALL paired desync/turn state (_desync_terminalized + _desynced_sessions alongside _turn_bind_epoch), not just the epoch. The epoch resets to 0 on delete, so a recreated same-id session restarts at the same epoch values — a leftover epoch-keyed _desync_terminalized claim could suppress the new session's terminal, and a stale _desynced flag could misclassify a later interruption. Regression test test_delete_session_clears_all_paired_desync_state (toggle-verified).
Round-8 follow-up (non-blocking lifecycle race): the bind epoch was a per-conversation counter that RESET on delete, so a same-id delete->recreate returned to the same epoch a stalled recovery still held (blocked inside _forward_harness_interrupt) — the old recovery then mistook the new lifetime for its original generation and published runner_turn_context_desync over the active replacement. Fixed by stamping the epoch from a process-wide, non-repeating sequence (itertools.count) in _begin_turn_slot instead of a per-conversation counter, so a recreated session's turn never reuses an epoch a recovery captured. Regression test test_resync_does_not_clobber_recreated_session_after_delete_mid_interrupt (delete + recreate injected while recovery is inside the interrupt await) toggle-verified. Full set 362 pass/1 skip; mypy 131.
Round-9 fix (nested-recovery token strip): the continuation branch popped _desync_terminalized UNCONDITIONALLY. If the replacement itself desyncs and its nested recovery re-claims the epoch-scoped token before the old recovery returns from its interrupt await, the unconditional pop stripped the replacement's token → its competing terminal was no longer suppressed → contradictory idle→failed. Fixed with compare-and-pop: release the token only when it still holds THIS recovery's _entry_epoch. Regression test test_old_recovery_does_not_strip_nested_recovery_token (nested recovery claims a higher-epoch token during the old recovery's interrupt await) toggle-verified. Non-blocking nits: corrected the delete_session + test comments that still claimed epoch-reset reuse (now non-repeating), and the delete/recreate test uses begin_turn_slot. Full set 363 pass/1 skip; mypy 131.
* fix(runner): publish idle when the cancelled turn's slot was already cleared
The drain's CancelledError handler guards its cleanup on the turn slot still
holding the current task, so a stale finalizer cannot clobber a newer turn.
That assumes the slot is cleared after the cancel — true for
_cancel_active_turn, but delete_session pops the slot before cancelling. On
that path the guard never holds, so the handler skipped its terminal publish
and _release_live_turn_markers, and the turn's own failure handler then
reported "failed". Deleting a session mid-turn left the client on a stale
"running" until that arrived.
An empty slot means no newer turn took over, so it is as safe to publish for
as our own task. Covered by
test_cancelled_turn_publishes_idle_so_client_unsticks, which regressed to
["running", "failed"] before this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(runner): restore _suppress_recovery guard; trim verbose comments
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(runner): trim verbose comments and simplify desync state
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(runner): trim verbose comments in process_manager
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(tests): consolidate turn-recovery tests; drop desync naming
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(tests): rename executor adapter and scaffold test files
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(tests): trim section comment in test_runner_policy
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(web): keep conversation streams open in the background
Switching conversations used to tear down the outgoing conversation's SSE
stream and wipe its state, so returning paid a reconnect plus a snapshot
re-fetch and briefly showed a stale/blank transcript. This keeps each
conversation's stream open and its state live in a per-conversation registry,
so returning to a backgrounded conversation paints instantly and is already
current — turns that ran while you were away are simply there.
Core change: split ChatState into conversation-scoped state that lives on a
per-conversation entry (`conversationRegistry`, an LRU with a transport-derived
live cap) projected onto the root store for whichever conversation is on
screen, and app-global state that stays on the root. The registry's unsent-work
pin replaces the old `pendingByConversation` stash: an entry holding a send the
server hasn't acknowledged is never evicted, so a mid-send switch-away can't
lose the message. `switchTo` no longer aborts or wipes; the pump keeps applying
events and reconciling across the ingress' ~5-minute stream recycle.
Everything that settles after an await now routes by the conversation it
belongs to (`setterFor(id)` / `applyToConversation`), not by what's on screen —
late attachment-id promotion, denied/failed sends, approval rollbacks, model
canonicalization, the sticky-pref handoff, `session.*` side effects, and
`loadMoreHistory` all land on the delivering/originating conversation. Liveness
(`isConversationStreamCurrent`), not visibility, decides whether to keep
pumping, whether a retained-but-dead entry must cold-rebind, and whether a
one-shot nudge (skills / model options / elicitation reconcile) still applies.
Per-conversation effort (`sessionReasoningEffort`) mirrors `sessionModelOverride`
so two live conversations keep their own effort across a warm switch. The
send-ordering chain is a per-conversation mutable box that migrates as one unit
when a new chat's id is published, so followers keep FIFO order. The live-cap is
derived from the negotiated transport (`getConnectionProtocol` reads the ALPN
id, not the URL scheme) so an HTTPS/HTTP-1.1 origin isn't treated as multiplexed.
Rebased onto latest main, reconciling with work that landed since the fork:
main's stranded-POST bounded wait (`SEND_CHAIN_MAX_WAIT_MS`) is folded into the
send chain; `failedSendDraft` retry, the optimistic-echo ack when the committed
copy already rendered, and the streamed-text reconciliation are ported onto the
registry model. Main's own tests for those behaviors are kept and pass against
the registry, confirming it subsumes the stash it replaced.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* feat(web): share the live-stream cap across tabs, warn when over budget
The live-conversation cap was per tab, but the resource it protects — the
browser's per-origin connection pool — is shared across every tab of the
origin. Two tabs at the serial cap of 3 each open 6 SSE streams and deadlock
every other fetch to the origin; someone hit exactly this exhaustion. The cap
is now origin-wide.
Coordinated through `navigator.locks`: N named slot-locks
(`omnigent:stream-slot:0..N-1`) taken with `{ ifAvailable: true }`, which
grants a free slot atomically or returns null — no query-then-acquire race.
A lock auto-releases when its tab closes or crashes, so a dead tab never
strands a slot. N stays the transport-derived number (30 multiplexed / 3
serial), only now shared. Where Web Locks is absent (jsdom, insecure
contexts) it degrades to a per-tab in-memory semaphore.
`bindStream` takes a slot before opening the stream. On saturation it
reclaims THIS tab's own LRU background stream, awaiting the real lock release
before retrying so it can't over-evict. A fresh tab that finds every slot
held by other tabs opens its active conversation anyway — over budget — and
raises `streamBudgetExceeded`; the banner tells the user to close tabs. No
cross-tab eviction: a background stream that can't get a slot stays cold and
rebinds on return, which keeping streams open already handles.
Registry eviction is now slot-driven. The count-based auto-trim in `acquire`
/ `setActive` is replaced by `evictLruEvictable(exemptId)`, called by the
slot layer, which disposes the LRU entry that is neither on screen, being
bound, nor holding unsent work.
StreamBudgetBanner floats below the chat header, dismissable per over-budget
episode (a fresh episode re-shows it).
Verified each slot test fails against the un-implemented feature; the real
Web Locks path is exercised through an injected fake, since jsdom has none.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): make the shared-cap round survive per-conversation state
Three review findings, each a place where state that used to be effectively
single-conversation is now per-conversation, and a global or misrouted value
outlives the assumption.
The stranded-send latch was one module scalar, but `status` is per-conversation:
two conversations can each hold a hung POST, and recovering one nulled the
single timestamp, so the other stayed "streaming" but could no longer age out —
its composer and queue wedged until reload. The latch moves onto the entry as
`ConversationState.sendLatchedAt`, set in the same patch as `status` so the two
can't diverge (a new chat buffers both on root and `adoptPreSessionState` moves
them together), read from `s.sendLatchedAt`, and cleared only on the recovering
conversation's own entry.
`browser_action_request` carries no conversation id and the relay is mounted for
the visible conversation, but a background conversation can now issue an action.
The bus dropped the delivering conversation, so the relay claimed at the visible
session and the server rejected the owner mismatch — the action never ran and
the agent's browser tool timed out. `emitBrowserActionRequest` now carries the
source conversation, and the relay claims, dispatches, and posts the result
against it rather than its mounted id.
`hasUnsentWork` — the eviction pin — only counted unsettled optimistic bubbles.
A failed send rolls its bubble back but stashes the text and files as
`failedSendDraft`, the only surviving copy. If that failure settled after the
conversation was backgrounded, nothing pinned the entry and eviction dropped the
retry draft. The pin now also holds while a `failedSendDraft` is outstanding, and
releases once the composer restores it on return.
Three tests added, each verified to fail against the unfixed code.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
---------
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(claude-sdk): emit CompactionInProgressEvent at PreCompact signal
Previously, both CompactionInProgressEvent and CompactionCompletedEvent
were emitted back-to-back after compaction finished, so clients never
actually saw the in-progress state.
The Claude SDK fires a PreCompact hook event during the streaming turn
before compaction completes. Add a CompactionStarted inner executor
event yielded at that point, and translate it in the adapter to
CompactionInProgressEvent — separate from the CompactionCompletedEvent
that follows when CompactionComplete is received.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* test(claude-sdk): assert CompactionStarted precedes CompactionComplete
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* style: ruff format
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(web): let a shared-with viewer leave a session
Every sidebar row action is owner-only, so a session someone shared with
you could only be cleared by asking its owner to revoke you. The revoke
endpoint couldn't serve it either: it required manage access AND blocked
self-modification outright.
Allow a self-revoke on the existing endpoint instead of adding a route.
Removing someone else still needs manage; removing your own grant needs
only read, since giving up access requires no privilege. The pre-existing
owner-grant check is what prevents orphaning, and it already covers the
self case — so an owner still can't leave (they archive or delete), while
a manage-level guest can. Leaving a sub-agent is refused, since its access
lives on the parent and revoking the child would delete nothing while
reporting success.
The sidebar gets a "Leave session" item on non-owned rows with a confirm
dialog, and a session_removed push so the row also drops from the leaver's
other open tabs. Nothing is deleted server-side, so the owner re-sharing
brings the session back.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* refactor(web): reuse the row's destructive slot for Leave, not a new item
The non-owner's row menu already had a Delete item rendered permanently
disabled ("only the session owner can delete this session") — a row that
could never do anything, sitting in exactly the slot Leave wanted.
Resolve that one slot by ownership instead of stacking a second item under
it: the owner gets Delete, a shared-with viewer gets Leave, reusing the
trash icon and destructive styling. Single-user mode keeps the plain owner
Delete. Net fewer lines in the menu than before, since the disabled branch
and its tooltip wrapper go away.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* refactor(web): drop the session_removed push; the diff already covers it
The self-leave path pushed a session_removed discovery event to the
leaver's own streams. It was marginal: the leaver's initiating tab already
drops the row via the mutation's onSuccess splice, and their other tabs
converge on the next watch-set diff (which reports the now-inaccessible id
as removed) — the handler even skipped the push whenever the row was
watched, to avoid double-reporting with that diff. Its only unique effect
was an instant drop for a listed-but-unwatched row in another of the
leaver's tabs, versus a one-refetch delay.
Unlike the session_added push (mandatory — a brand-new session is
undiscoverable by the watch-set diff), the removal is always discoverable,
so this push isn't load-bearing. Drop it and the client-side removed
handler stays as-is (still driven by the diff). Owner-side roster liveness
(the Share modal reflecting a grantee leaving) is a separate follow-up.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(web): satisfy oxlint — top-level type import + string toast
CI's oxlint (which runs --deny-warnings, and couldn't run locally due to a
stale-config/version mismatch) flagged two issues in the leave changes:
- Sidebar.rowActions.test.tsx used an inline `typeof import("@/lib/identity")`
type annotation, forbidden by typescript/consistent-type-imports. Switched to
a top-level `import type * as IdentityModule`, matching the repo idiom.
- The leave onError handler passed inline <span> JSX to showToast, which
react/no-unstable-nested-components reads as a component defined during
render. showToast takes a ReactNode, so pass a plain string instead.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
---------
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Point OMNIGENT_URL at a bare Databricks workspace origin and npm run dev
auto-fills the /api/2.0/omnigent api-proxy mount and emits the host_id slice
key on host-scoped traffic (build-time VITE_DATABRICKS_WORKSPACE flag + the
unified isDatabricksWorkspace() gate), so the standalone dev bundle shards like
the embedded UI. An explicit mount or a local server is unaffected.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Co-authored-by: Isaac <isaac@example.com>
Adds a per-host routing key (the ``X-Databricks-Omnigent-Slice-Key`` header)
so that, on a horizontally-scaled multi-tenant deployment, every request
scoped to a given host or session lands on the replica that holds that host's
runner tunnel: the host's control tunnel, its runners' tunnels, and all of a
session's turn/resource/stream traffic converge on one replica when they carry
the same key (the host_id). On an unsharded / single-replica deployment the key
is never emitted, so this is a no-op there.
Client-side only. The key is built centrally in
``cli_auth.databricks_request_headers`` (gated on the workspace-hosted mount)
and threaded through the one factory ``open_server_client`` plus
``_remote_headers`` / ``open_daemon_client``. Callers pass a host_id when they
have one; runner-side callers (forwarders, permission checks) inherit it
automatically from the ``OMNIGENT_RUNNER_SLICE_KEY`` env var the host stamps at
runner launch, so no per-callsite change is needed there. The WebSocket attach
handshake and its reconnects carry the same key.
``chat._remote_headers`` gains a ``host_id`` keyword (defaulting to ``None`` so
probes and health checks are unaffected). ``_DatabricksTokenAuth`` resolves the
session's host per request from the session→host map and can be repointed via
``pin_session`` when a client outlives its session (e.g. a ``--fork`` in the
REPL lands under a new conversation id on a new host). Session-host state is
always written on attach — clearing a stale mapping when the server reports no
host matters as much as setting one.
A ``tests/cli`` conftest fixture isolates the runner machine's own host
identity so "no slice key on this call" assertions are hermetic regardless of
whether the box running the suite is itself a host.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(deps): declare tzdata on Windows so the server can start
`zoneinfo` has no time-zone data on Windows unless the `tzdata` wheel is
installed, so `ZoneInfo("UTC")` raises ZoneInfoNotFoundError there. Two
scheduler modules evaluate it at module top:
omnigent/server/scheduled/rrule.py:32 _UTC = ZoneInfo("UTC")
omnigent/server/scheduled/scheduler.py:47 _UTC = ZoneInfo("UTC")
Both are pulled onto the core server boot path via server/app.py, so a
clean Windows install crashes during import on `omnigent server start`
before any port is bound:
File ".../omnigent/server/scheduled/rrule.py", line 32, in <module>
_UTC = ZoneInfo("UTC")
File ".../zoneinfo/_common.py", line 24, in load_tzdata
raise ZoneInfoNotFoundError(f"No time zone found with key {key}")
zoneinfo._common.ZoneInfoNotFoundError: No time zone found with key UTC
The same gap affects user-supplied timezones at run time
(scheduler.py:93, routes/scheduled_tasks.py:164). POSIX platforms use the
system database and are unaffected, which is why the dependency is marked
rather than unconditional.
uv.lock regenerated with `uv lock` under WSL2 and normalized with
scripts/normalize_uv_lock_registry.py, per CONTRIBUTING.md's note that
native Windows is unsupported for development.
Verified: `uv tool install omnigent --with tzdata` starts the server
normally on Windows 11 / CPython 3.12.
Signed-off-by: Injun Lee <2006ijlee@gmail.com>
* fix(deps): upgrade cryptography, gitpython, h2 to resolve security scan CVEs
- cryptography 48.0.1 → 49.0.0 (PYSEC-2026-3552/3553/3554)
- gitpython 3.1.57 → 3.1.58 (GHSA-9rj7-rf2p-w77r, GHSA-4gmw-gg2m-w46p, GHSA-hh9p-6wh2-4mfc, GHSA-wvpp-8hx9-p66j, GHSA-jm78-9fvv-mhgr)
- h2 4.3.0 → 4.4.1 (CVE-2026-71554)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Injun Lee <2006ijlee@gmail.com>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): add bulk move-to-project action in sidebar selection mode
The bulk action bar only had archive and delete buttons. When multiple
sessions were selected there was no way to move them into a project
without dragging each one individually.
Add a useBulkMoveToProject hook that moves sessions in parallel (same
pattern as bulk archive/delete) and a folder-icon dropdown in the bulk
action bar with a searchable project picker. On success the target
project folder expands and selection mode exits.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* style(web): fix prettier formatting in BulkActionBar
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(web): add useBulkMoveToProject mock to sidebar test files
The new hook import caused vitest to fail with "No
useBulkMoveToProject export is defined on the mock" in every sidebar
test file that mocks @/hooks/useConversations. Add the mock entry
alongside the existing useBulkArchiveConversations and
useBulkDeleteConversations mocks.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(web): add useBulkMoveToProject mock to Sidebar.test.tsx
Missed in the prior commit — the glob pattern didn't match the
base test file.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(web): fix tooltip on bulk move-to-project button
The controlled open state on the DropdownMenu was suppressing the
Radix tooltip. Switch to an uncontrolled DropdownMenu (matching the
SessionFilterMenu pattern) so the tooltip appears on hover.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
Server-side changes:
- Add WRONG_REPLICA error code (400) to errors.py for host-sharding misroutes
- Thread host_id through RunnerRouter to classify misses as WRONG_REPLICA (keyless re-addressable) vs RUNNER_UNAVAILABLE
- Add WrongReplicaWSError exception and WS_CLOSE_WRONG_REPLICA (4400) for terminal attach
- Guard session send/stream routes against wrong-replica routing to raise WRONG_REPLICA before healing attempts
- Guard session create against wrong-replica routing of host-bound creates
- Wire host_registry and host_store into RunnerRouter in app.py for classifier functionality
- Replace host-offline HTTPExceptions with _host_absent_error classifier on all host-scoped routes
Web-side changes:
- Add full slice-key keying to authenticatedFetch: X-Databricks-Omnigent-Slice-Key header on host/session-scoped requests
- Implement session→host_id map (sessionHost.ts) for client-side routing
- Add host-resolve bootstrap to prevent early requests from keyless fallback on fresh page load
- Implement keyless-host demotion (evidence-based sticky fallback for keyless-routed hosts)
- Handle wrong_replica 400 response with keyless re-address retry in fetch wrapper
- Port terminal-attach WS slice-key keying and 4400 close handler (next steps beyond this commit)
Excludes: SAFE gates, DATABRICKS-PATCH markers, live-state fields, CLI client files.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* feat(web): add Usage page with session cost tracking
Add a dedicated Usage page accessible from the sidebar that shows:
- Total cost summary with session count
- Daily cost bar chart with gap-filling
- Cost breakdown by harness and by model (horizontal bar charts)
- Sortable session table with cost, harness, model, and last-active columns
- Time range selector with presets (7d/30d/90d/All time) and custom date range
Backend changes:
- Add list_daily_costs store method for the daily cost timeline
- Extend SessionUsage schema with harness, llm_model, agent_name fields
- Add DailyCost model and daily_costs field to UsageReport
- Add _resolve_session_harness() with 3-tier fallback: harness_override,
wrapper label, agent-spec resolution
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* chore: regenerate openapi.json for usage report schema changes
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* test(ui-snapshot): update visual baselines for Usage nav item
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* test(ui-snapshot): update visual baselines
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
`omnigent resume` (no id) opened a cross-agent picker over
GET /v1/sessions, which returns every session the caller can *access* —
including ones merely shared with them. Resume is owner-only (the server
rejects binding a runner to a session you don't own), so a shared row in
the picker was a dead end.
Resolve the caller's identity via a best-effort GET /v1/me in the resume
dispatch and pass owner_user_id to pick_conversation_cross_agent_from_sdk,
which now drops rows the caller does not own. An unresolved identity
(unauthenticated / transient failure) or a permissionless single-user
server (owner unset, no sharing) leaves the list unfiltered — resume
never breaks.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
`_usage_from_result` mapped only input/output/total, so an agent's
`cachedReadTokens` was silently discarded. Cache reads are real consumption
billed at a fraction of the input rate, so dropping them misreports a turn: in
one measured Devin turn 10,944 of 15,637 input tokens were cache reads.
Map it to `cache_read_input_tokens` — the key the SSE layer and AgentInfo
already speak — so it renders with no UI change, and keep it distinct from
`input_tokens` rather than folded in, since the two are priced differently.
Also tighten the value check: `bool` is an `int` subclass, so a stray `true`
would previously have been reported as a token count.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(host): generate host_id when config.yaml provides only a host name
load_or_create_host_identity only honored the config.yaml host section
when it carried both host_id and name. A user who hand-wrote a config
that names the host but omits host_id fell through to the create path,
which overwrote their chosen name with the machine hostname and minted
a fresh id.
Complete a partial host section instead of discarding it: keep any
provided value, generate only what's missing, and persist so the id is
stable across calls. This matches set_sandbox_host_name, which already
uses setdefault('host_id', ...) to fill in the id while preserving name.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* test(host): e2e-verify name-only config gets a generated host_id
Boots the real server and host daemon with a config.yaml that names the
host but omits host_id, then asserts the host registers under that name
(not the machine hostname) with a freshly generated id that matches what
the daemon persists back to config.yaml.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
---------
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* feat(acp): apply /model to a live session without losing the transcript
A `/model` pick reached the ACP executor as `ExecutorConfig.model` and was
ignored — the agent kept running whatever model it launched with, so the
override silently did nothing until the process was respawned (and respawning
costs the conversation).
ACP standardises `session/set_config_option`, so switch the live session
instead: the agent keeps its context and the new model applies from that turn
on. Gated on the agent advertising a `model` option via `config_option_update`,
which is also the only trustworthy record of which model is active — an agent's
self-report is not (Devin reports `FAMILY=SWE` after switching to Gemini).
A rejection latches the feature off for the process instead of re-requesting
every turn, and never fails the turn: an agent that cannot switch should still
answer on the model it has.
Note the parameter is `configId`, not `optionId` — the latter fails with
`missing field 'configId'`.
Verified against a real `devin acp`: swe-1-7-medium -> swe-1-7 mid-conversation,
with a token planted before the switch still recalled after it.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(acp): trust the agent's echoed model over the requested id
Polly review of the warm /model switch: after a successful
session/set_config_option, _apply_model_override recorded the agent's echoed
currentValue and then unconditionally overwrote it with the requested id. An
agent that accepts the call but reports a different currentValue (normalizes
it, or silently keeps its model) would leave _active_model reflecting the
request, not reality — so a later turn would skip a switch it should retry.
_note_config_options now returns the echoed model value, and the caller falls
back to the requested id only when the agent echoed no model option at all.
Adds tests for the echo-differs and no-echo-fallback cases — the existing mock
echoed currentValue == request, so it couldn't catch this. Also refreshes a
stale comment that described /model as respawning the subprocess; it no longer
does.
Verified live against devin acp: swe-1-7-medium -> gemini-3-1-pro-low, a real
cross-family switch confirmed by Devin's own currentValue echo, with a token
planted before the switch recalled after it.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(host): keep the session workspace off the runner's sys.path (OMNI-2963)
Opening a session inside an omnigent checkout ran a different omnigent than
the installed one. Runners are spawned with `python -m`, which prepends the
process cwd to sys.path, and since 3419de8d the runner's cwd is the session
workspace, so a workspace that is itself a checkout won over site-packages.
A long-lived daemon plus a mid-flight `git pull` then left the host and the
zygote on different code, surfacing as "runner fork request requires a cwd".
Spawn the runner, the zygote and the harness runner with -P so cwd never
lands on sys.path, and re-add the workspace in the runner entry once the real
omnigent is imported (and so can no longer be shadowed), keeping
spec-declared local tools importable by dotted path. Also pass -I to the
hermes MCP bridge, the only native bridge that was missing it.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* chore(tests): reword the shadowing docstrings
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(tests): hand spawned harness children the project root via PYTHONPATH
Harnesses now spawn with -P, so a directly-exec'd harness no longer inherits
the repo root through its cwd. Tests that register a fixture harness module
(tests._fixtures.runner_test_harness) must pass that path in the environment,
which is what tests/runtime/harnesses/conftest.py already does; mirror that
fixture for tests/runner. Also update the hermes MCP-config assertion for the
added -I, matching the qwen bridge test.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(cli): spawn the local runner with -P too
The CLI's own runner spawn inherits the CLI's cwd, so running omnigent from
inside a checkout shadowed the installed package exactly as the daemon path
did. Raised by review; the earlier audit missed it because this argv sits on
one line.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(codex-native): pass -I to the codex serve-mcp bridge
codex_mcp_config_overrides built its own args list without -I, so the one
bridge codex launches stayed open to the workspace shadowing that every other
bridge already blocks. Raised by review, which also caught that the PR
description wrongly claimed codex already had it.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
---------
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
Make `omnigent run --harness acp:<slug> --server <remote>` work by resolving
the slug client-side at launch time and embedding the ACP agent in the
temporary spec. Previously, the server would fail because it couldn't resolve
acp:<slug> from its own local config when the agent was only configured on
the client.
The fix is additive and capability-gated: the existing config-lookup path
remains as fallback for specs authored by hand. No branching on agent names.
Embed all ACP agent fields (name, command, model, session_id_mode, send_model,
omnigent_mcp, env_passthrough) in the temporary spec so the remote server sees
the same agent config as the client. Qwen-shaped agents with `session_id_mode:
client` + `send_model: true`, agents with `omnigent_mcp: false` or
`env_passthrough` settings now preserve their critical config knobs across
--harness acp:<slug> embedding.
What breaks if this fails:
- Remote server can't resolve `acp:<slug>` when the agent is configured locally
only on the client, resulting in "request-time error" (HARNESS_ACP_COMMAND
missing) at runtime.
- Agents with non-default settings (Qwen with client-side session ids, agents
with omnigent_mcp disabled, agents requiring environment passthrough) silently
lose these config knobs when embedded, causing incorrect spawn behavior
(Qwen spawns with server-side session ids, auth env vars unreachable).
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
`ACP_CLI_HARNESSES` rows were registry-complete but invisible: `grok` was in
`harness_labels`, `valid_harnesses` and the `/v1/harnesses` catalog, and
`harness_install.py` even generated it a "Sign in to Grok Build" step — but the
`omni setup` overview builds its rows by hand and never read the catalog, so the
row (and that step) were unreachable. A shipped harness was therefore *less*
discoverable than a user's own `acp:` config entry, which is backwards.
Render one row per catalog entry, next to Goose (the other ACP-family builtin),
with a drill-in naming the install hint, the vendor login command and how to
launch it. Derived from the catalog, so a new row surfaces here for free.
These rows own their auth, so the status reports whether the binary is on PATH
but claims nothing about sign-in state.
The overview's row indices shift by one after Goose; the scripted-stdin dispatch
tests are updated accordingly and now pin the new row too.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The boot-time /v1/info probe races a 1.5s timeout so the app still paints when the probe is slow or missing. But both entry points then kept that fallback for good: EmbedCapabilitiesProvider's effect ran once and never re-read the resolved value, and main.tsx rendered a single time inside bootProbe.then(). On a slow-but-successful probe -- e.g. a proxied /v1/info behind a busy server, which routinely exceeds 1.5s -- the real capability set never reached the UI, so capability-gated affordances (most visibly the managed "<provider> Sandbox" host option) stayed hidden for the tab's lifetime until a full reload.
Adopt the real /v1/info value when it lands: keep the 1.5s fallback for first paint, but replace it once resolveServerInfo() resolves. embed.tsx does it via state; main.tsx re-renders the same root. resolveServerInfo caches and never rejects, so this shares the boot probe's single fetch and the real value can only render at or after the fallback -- never a downgrade.
Add a tests/e2e_ui start_session Playwright test that delays /v1/info past the budget and asserts the managed-sandbox host option still appears.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Archiving a session the user is currently viewing left them stranded on
the now-archived session's URL. Mirror the existing delete-flow
behavior: check whether the active session matches the one being
archived and navigate to "/" on success. Applies to both single-session
and bulk archive paths.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* feat(server): offer multiple sandbox providers at once
The server could configure exactly one sandbox provider. `sandbox.provider`
was a scalar validated against a frozenset, `ManagedSandboxConfig` held a
single `launcher_factory`, and the web UI rendered one picker row labeled from
`/v1/info`'s `sandbox_provider`. Eight providers ship and work, but a
deployment had to pick one at boot. The CLI already accepts any of them per
invocation (`omnigent sandbox --provider`), so this extends that to the server.
`sandbox:` now also takes a `providers:` list, mutually exclusive with the
scalar `provider:`. `server_url` / `host_config` stay top-level and ride into
every entry; each entry names its provider and may carry that provider's own
block, validated by the same parser as before. `ManagedSandboxConfig` gains a
`providers` tuple plus `offered()` / `for_provider()` / `recorded()` /
`launchable_providers()`, with the scalar fields still describing the first
provider so existing callers and the direct-construction embedding path are
untouched.
Teardown, resume, and relaunch now resolve a launcher by the provider recorded
on the host row rather than comparing against the one current launcher, and
re-arm with that provider's own token TTL and host_config. Without this a host
launched on one provider could be handed another's launcher. The per-host
`sandbox_provider` column already exists and is already written on every path,
so no migration is needed.
`GET /v1/info` adds `sandbox_providers` (launch-capable only, so a staged
provider like lakebox stays configurable but is never offered) while
`sandbox_provider` keeps naming the first. `POST /v1/sessions` adds an optional
`sandbox_provider`, rejected on `host_type: "external"` and validated
synchronously so an unconfigured name is a 400 at create instead of a
background launch failure. Omitting it takes the first provider, which is what
every request written before this change sends.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
* refactor(server): extract ManagedSandboxDeployment; provider-sticky picker
Address review feedback on the multi-provider sandbox work.
- Split the self-nesting config: ManagedSandboxConfig is single-provider
again, and a new ManagedSandboxDeployment holds one config per offered
provider plus the offered/for_provider/recorded/launchable_providers
accessors. create_app wraps a bare embedding config via
ManagedSandboxDeployment.single, so the direct-construction API is
unchanged.
- Derive the deployment default from the first launch-capable provider,
not entry [0], so a staged provider (e.g. lakebox) listed first no
longer disagrees with managed_launch_supported.
- Seed the new-session sandboxProvider from the sticky last pick (or the
first offered row) at every auto-select site, persist it in the landing
draft, and store it via read/writeLastSandboxProvider so the composer
reopens on the provider used last and highlights its row.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
* refactor(server): guard ManagedSandboxDeployment against empty configs
The accessors (default indexes configs[0]) rely on a non-empty configs
tuple. The parser already rejects an empty providers list, but a direct
constructor could pass configs=() and IndexError cryptically later.
Enforce the invariant in __post_init__ with a clear message.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
* fix(web): satisfy CI prettier 3.9.5 and oxlint no-shadow
CI pins prettier 3.9.5 (root lockfile), which collapses the mentionEntries
.map() callback differently than my local 3.8.4 formatted it — reformat to
match. And drop the redundant top-level resolveServerInfo import in
capabilities.test.ts: the probes re-import it dynamically for a fresh module
cache, so the static import only shadowed those and tripped oxlint's
no-shadow warning.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
* test(e2e-ui): cover multi-provider sandbox selection flow
The E2E-UI-required gate (an AI judge) flagged that the multi-provider
sandbox picker changes user-facing behavior with only unit/component
coverage. Add a Playwright test under tests/e2e_ui/ that drives the flow:
a multi-provider server renders one row per provider, picking the
non-default (E2B) rides into the create POST as sandbox_provider and
labels the chip, and the pick survives a reload (sticky provider).
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
* fix(test): add .default to blaxel parse tests after ManagedSandboxDeployment split
Blaxel was added to main (#4383) after this PR; its tests call
parse_sandbox_config and access .server_url/.launcher_factory/.token_ttl_s
directly, but parse_sandbox_config now returns ManagedSandboxDeployment.
Add cfg = cfg.default — the same pattern every other parse test uses.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
---------
Signed-off-by: Mark Tai <mark.tai@databricks.com>
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Co-authored-by: Mark Tai <mark.tai@databricks.com>
Co-authored-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* feat(sessions): promote a sub-agent by forking it to top level
A sub-agent that uncovers a larger body of work had no way to outlive its
parent. The fork route rejected any sub-agent source, so keeping that work
alive meant keeping the parent session alive purely as an anchor, with the
real work never appearing in the sidebar.
Forking already produces what promotion needs. The store builds every fork
as a fresh top-level row (its own spawn-tree root, no parent, kind
"default", no sub_agent_name) and the route grants the caller LEVEL_OWNER,
so relaxing the source check is the feature: the promoted copy reaches the
sidebar, survives its parent's deletion, and leaves the running source
untouched under its parent.
Sub-agent marker labels neutralize themselves on a fork, since the
codex/claude sub-agent predicates gate on parent-nullness. The wrapper and
ui labels do not: they are read raw, and a copied
claude-code-native-ui-subagent would strand the promoted session in a
child's UI mode with no terminal of its own. Recompute those for the
harness the fork actually binds, reusing the agent-switch path.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(web): give a sub-agent's fork the same host and directory choices
A sub-agent records no host or workspace of its own — the parent owns the
tmux pane and the cwd, and the child row inherits only runner_id — so the
fork dialog read it as a session with no working directory and collapsed to
its name-and-agent form. Promoting a child offered a visibly smaller dialog
than forking anything else, and its "Clone" (rather than "Clone & start")
created the promoted session unbound.
That also made the state self-propagating: an unbound promoted session has
no workspace either, so forking IT collapsed the dialog again, and a session
promoted out of a sub-agent could never fork like a regular one.
Back the child's missing values with its parent's sidebar row, which already
carries both. Host and workspace are written together and a host binding
requires a workspace, so the pair stays coherent, "Clone & start" binds the
promoted session to a real directory, and its own forks then prefill
normally. The dialog's existing same-directory warning covers the overlap
with the still-running parent.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
---------
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
`omnigent run --harness acp:devin` silently ran a different agent. The
reverse translator canonicalized the namespaced generic-ACP id down to the
base `acp` harness, so by spawn time the slug was gone and
`_build_acp_spawn_env` fell back to the first configured `acp:` agent —
launching e.g. kilocode while the UI still reported the requested agent.
Keep the full `acp:<slug>` id for ACP and canonicalize everything else, so
harness aliases still resolve. This mirrors the logic
`_materialize_harness_launcher_file` already applies in `omnigent/cli.py`.
The existing spawn-env tests build `AgentSpec` directly and so never
exercised the translation where the slug was lost; the new regression test
goes through `agent_def_to_agent_spec`, the path a YAML launch actually takes.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
localHostId() read this machine's host id verbatim from config.yaml and handed the renderer the legacy "host_<hex>" spelling, while the server always reports the bare hex form (hosts.host_id is a Uuid16 column — 16 raw bytes on disk, bare-only by construction). The host picker compares the two as plain strings, so on installs created before the prefix was dropped, "this machine" never matched a /v1/hosts row.
The visible result: the machine's row was never deduped (a redundant "Run on this machine" showed even when it was already online in the list), the chip read the raw hostname instead of "This Mac", and clicking "Run on this machine" left the selection empty once connecting finished.
Strip the prefix in localHostId(), mirroring _normalize_host_id in omnigent/host/identity.py — the desktop shell was the one place in the stack that did not already normalize every id spelling to bare hex. A bare 32-char hex id can never begin with "host_" (none of h, o, s, t, _ are hex digits), so the strip is a no-op on new ids and cannot corrupt them.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
A sub-agent resolved by name was handed the PARENT's bundle root as its
workdir, so the child booted with the parent's skills and local tools
loaded — a privilege leak across the agent boundary. It reached every
harness: the parent's skills landed on claude-native's `--plugin-dir` and
codex-native's `CODEX_HOME`, and `HARNESS_*_BUNDLE_DIR` pointed the wrapped
harnesses (claude-sdk, codex, pi, cursor, copilot) at the parent's assets.
Provenance. `AgentSpec.source_rel_dir` records the directory each sub-agent
was parsed from, stamped by the parser from the DIRECTORY name and never the
YAML `name` — the two may legitimately differ. The field is `compare=False`
and is never serialized, so spec equality and the on-disk bundle format are
unchanged, and no migration is needed.
Resolution. `_resolve_sub_agent_spec_entry` walks the identity chain from
parent to child, composing one `agents/<dir>` hop per level, and returns the
child's spec and bundle dir together. `ResolvedSpec.workdir` widens to
`Path | None`: anything the resolver cannot prove — a spec unreachable in
the tree (the synthetic `__web_researcher`), an unsafe path segment, a
directory absent on disk — yields `None`, so the child registers nothing
rather than inheriting the parent's bundle. The segment guard is a
component check rather than a substring reject, so a directory legitimately
named `review..worker` still resolves while `..`, `a/b` and absolute paths
do not.
Call sites. Every place that swaps a parent spec for a named sub-agent now
routes through that one resolver: session init, turn dispatch,
`_resolve_session_spec_entry` (the trunk both native terminal ensures ride),
`_resolve_harness_config`, the `/mcp/execute` spec-local tool path, and the
claude/codex terminal-ensure paths. In turn dispatch the entry is re-read
from the spec cache first, and the workdir is swapped BEFORE local-tool
paths are resolved so relative paths root at the child.
Fallback semantics. `_resolved_workdir_for_spec` now honours a wrapped
entry's `None` instead of widening it to the runner workspace. That
distinction is load-bearing: a wrapped entry has been resolved and its
answer stands, while a bare spec never carried bundle information and keeps
the previous `runner_workspace` fallback, so ordinary top-level sessions are
unaffected. Builtin (non-spec-local) tools keep the workspace, which is
correct for them. `ToolManager` accepts a `None` workdir and skips local-tool
registration. `_rewrap_like` carries that same rule at the turn-dispatch cache
write: re-wrap only when the previous entry was wrapped, so a bare spec is not
promoted into a wrapper that would assert a bundle verdict it never had.
The claude-native / codex-native temp-bundle paths are deliberately
unchanged: each already mints a fresh empty dir seeded only with the
framework-owned `build-omnigent` skill, which is not parent content. A test
pins that so the claim stays true.
Docs. Permitting `name != directory` made every doc asserting the opposite
wrong, across four renderings: prose ("must have a corresponding
directory"), path templates (`skills/<name>/SKILL.md`), tree diagrams
(`<skill-name>/`), and the `build-omnigent` generation template, which
reused a single token as directory name, `tools.agents` entry and `name:` —
actively teaching generating agents to derive the path from the name. All
are corrected across AGENTSPEC.md, the validator's prose and error text, the
bundled onboarding skills, the example agents and the docstrings that mirror
bundle layout; `openapi.json` is regenerated for the coupled `schemas.py`
description. Worked examples now demonstrate the independence (`name:
critic` in `agents/code-critic/`) rather than only asserting it. Also
corrected a contradiction found in the same region: only the PARENT needs
the `omnigent` executor — sub-agents may use any executor, which is what
lets one orchestrator drive children across different harnesses.
This change also carries the merge with upstream/main. Upstream added a
`_warn_unresolved_sub_agent` log to the `else` of each sub-agent spec-swap
site; that logging is preserved at all four sites alongside the resolver
call. The resolver returns `None` on a lookup miss and the surrounding code
leaves the parent entry in place, which is exactly the fallback upstream's
message describes, so the warning stays accurate.
Tests cover all 7 harnesses: claude-sdk / codex / pi / cursor / copilot
assert `HARNESS_*_BUNDLE_DIR` is the child dir or absent and never the
parent's, and claude-native / codex-native assert the terminal-ensure
`bundle_dir`. Also covered: the grandchild chain, the synthetic
`__web_researcher`, segment validation, wrapper survival across turn
dispatch's double cache write, child-rooted relative `local_tools` paths,
and the isolated-framework-bundle pin.
Closes#3525
Signed-off-by: Andrew Reid <andrew@reid.ee>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
_wait_for_posts(slack, 1) was satisfied by the "Working on it…" ack
before stop_with() could delete it and post the error. The turn runs as
a background task, so shutdown() cancelled it before the error post
landed. Added _wait_for_ack_deleted to block until both the ack
deletion and follow-up post have completed.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
A native assistant message whose commit failed to reconcile against its
streamed deltas — a lost/reordered/mismatched delta leaves the aggregate
neither equal to nor a prefix of the committed text — was left in
`_native_inflight` forever. That map has no TTL/LRU, and native turns end
via `session.status: idle` (which deliberately spares native buffers)
rather than a terminal `response.*`, so nothing evicted the stale entry.
`snapshot_for` then replayed it as a phantom live preview on every
reconnect, and the client retired the wrong preview bubble on the next
commit — surfacing as a duplicate assistant message.
Native text commits in stream order, so when a message commits every
earlier un-claimed aggregate is superseded. Evict them in the
`output_item.done` handler: older-than-the-match on an exact/prefix hit,
and all-but-the-tail when the commit reconciles nothing. Only
`_native_inflight` (the streaming-preview plane) is pruned; committed
items still pass through unchanged, so over-eviction can at worst drop a
live preview the committed item then supplies.
Co-authored-by: Isaac
* perf: skip redundant session GET and cache token mint on omni startup
Two client-side optimizations that reduce `omnigent claude --server`
startup latency by ~4s on the critical path:
1. Cache `_stored_databricks_record_token` per server URL within the
process lifetime. CLI startup calls `_remote_headers` twice for the
same URL in quick succession (once in the Databricks auth probe,
once to build session headers), each minting a fresh OAuth token via
the SDK at ~1.4s/call. The second call is now instant.
2. Add `fresh=True` to `launch_or_reuse_daemon_runner` for newly-created
sessions. The function previously always fetched `GET /v1/sessions/{id}`
to check for an existing runner binding before launching — a ~2.8s read
that is always empty on a brand-new session. Fresh sessions skip
straight to `POST /v1/hosts/{id}/runners`.
Both changes are applied across all native harnesses (claude, codex, pi,
kiro, cursor, antigravity, goose, hermes, qwen, kimi, opencode) and the
chat run path. Resume and fork paths are unaffected — `fresh=False`
preserves the existing reuse/stale-clearing behavior.
Profiled savings (omnigent claude --server, remote Databricks workspace):
token mint dedup: ~1.4s
session GET skip: ~2.8s
total client-side: ~4.2s
target: ~7s wall time (down from ~11s)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(perf): replace lru_cache with 60s TTL cache for Databricks token mint
lru_cache persists for the process lifetime — safe for short-lived CLI
invocations but risky in long-running contexts (daemons, servers) where
a cached token would silently expire after ~1h and cause 401s.
Replace with a module-level dict cache with a 60s TTL: long enough to
cover the startup sequence where _remote_headers is called twice in quick
succession for the same URL, short enough to never serve a stale token.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(perf): cache _DatabricksBearerAuth object instead of token string
The previous TTL cache stored the token string, which required a new
_resolve_databricks_auth call (rebuilding the SDK Config) after 60s.
The SDK Config itself caches the OAuth token in memory and only shells
out to the Databricks CLI when the token nears expiry — so caching the
auth object is both faster and correct for long-running callers: repeat
calls within the token TTL are instant, and calls after expiry let the
SDK refresh transparently rather than serving a stale string.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* perf: parallelize auth probe, daemon start, session create, and host-online wait
Three concurrent-startup optimizations on top of the existing GET skip
and token-cache changes:
1. Auth probe ∥ daemon start (_ensure_backend, cli.py):
GET /v1/me (~0.65s) and the host daemon tunnel start (~2s) are
independent. Run them in a ThreadPoolExecutor so the auth check is
hidden under the longer daemon wait. Auth errors are surfaced first
(more actionable than a tunnel error caused by missing creds).
2. Session create ∥ host-online poll (_prepare_claude_terminal_via_daemon):
POST /v1/sessions (~2s) and GET /v1/hosts/{id} polling (~0.2s) are
independent. Use asyncio.gather so the host check is hidden under
the session create.
3. Session create ∥ daemon start (combined):
_ensure_host_daemon (~2s) is now passed as ensure_daemon callable
into _prepare_claude_terminal_via_daemon and run via asyncio.to_thread
concurrently with POST /v1/sessions. This collapses the two largest
sequential waits (daemon + session, previously ~4s sum) into
max(daemon, session) — roughly ~2s.
Measured savings: ~1.7s additional wall-time reduction on top of the
earlier ~1s from GET skip + token cache (total ~2.7s vs baseline).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(perf): correct parallelization — auth∥daemon in _ensure_backend, session∥host in async path
The previous commit had a bug: _ensure_host_daemon was called twice —
once in _ensure_backend (change 1) and again via ensure_daemon inside
_prepare_claude_terminal_via_daemon (change 3). The second call was
redundant since the daemon was already up.
This commit corrects the structure:
- _ensure_backend (remote path): auth probe (GET /v1/me) ∥ daemon
tunnel start via ThreadPoolExecutor — auth check hidden under the
~2s daemon wait (change 1).
- _prepare_claude_terminal_via_daemon: POST /v1/sessions ∥
GET /v1/hosts/{id} via asyncio.gather — host-online check hidden
under the ~2s session create (change 2).
- _run_with_remote_server no longer calls _ensure_host_daemon at all;
that responsibility belongs entirely to _ensure_backend, which is
called by cli_native before _run_with_remote_server is invoked.
Update test to reflect new architecture: _ensure_host_daemon is
_ensure_backend's responsibility, not _run_with_remote_server's.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(tests): add fresh keyword arg to fake launch_or_reuse_daemon_runner stubs
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* perf: run wait_for_runner_online ∥ _wait_for_claude_terminal_ready on fresh launches
On a fresh launch the runner auto-creates the terminal on session-start,
so the CLI can start polling for the terminal immediately after the runner
launch is requested — it returns None (404) until the runner boots and
creates it. Running both waits concurrently via asyncio.gather saves the
full wait_for_runner_online duration (~1.4s typical) since the terminal
poll covers the same window.
The runner-online wait is preserved on the resume path (where
_ensure_claude_terminal_on_runner must be sent to an online runner), and
its fail-fast dead-runner signal still fires on fresh launches since
gather propagates exceptions from either coroutine immediately.
Update test to reflect the merged progress step.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci: retrigger CI
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): wrap long unbroken chat text/inline-code instead of overflowing
A long unbroken run — a hash, an id, an inline-code span with no
spaces — has no break opportunity in the chat prose (Streamdown's
default inline-code style is plain `rounded bg-muted ...`, no
overflow-wrap) and the message bubble lacks min-w-0 as a flex item of
the transcript column. The unbroken run then forces the whole
transcript scroll container wider than the viewport: at narrow widths
the chat area itself gains a horizontal scrollbar, and for a user
bubble (which clips overflow) the tail of the text is silently cut
off instead of shown.
- Message (message.tsx): add min-w-0 so the bubble can actually
shrink to the column's width instead of demanding its content's
full intrinsic width.
- MessageResponse's Streamdown root: add wrap-anywhere
(overflow-wrap: anywhere), inherited into every prose descendant
(paragraphs, list items, inline code) so an unbroken run wraps
instead of overflowing. Fenced code blocks are unaffected — they
pin white-space: pre (or pre-wrap via the existing wrap toggle,
which already sets its own overflow-wrap).
- index.css: reset table cells back to overflow-wrap: break-word,
mirroring the existing link-in-cell exception — `anywhere` shrinks
a cell's min-content to ~1 char, which would let one long-word cell
squeeze every other column in an auto-layout table.
Verified live against the Vite dev server at a narrow viewport
(documentElement/transcript scrollWidth <= clientWidth before vs.
after) and confirmed the fenced-code scroll/wrap toggle and table
column widths are unchanged.
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
* test(web): trim overly specific comment/name on the Message shrink test
Rename to a short behavior name consistent with the surrounding tests
and drop the OMNI-2900-specific regression comment; the min-w-0
assertion and the caller-width-override test are unchanged.
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
* test(e2e): cover chat long-text/inline-code wrap at a narrow viewport
Seeds a deterministic assistant message (external_assistant_message,
no LLM run) with a long unbroken plain-text run and a long unbroken
inline-code token, and asserts the observable geometry at a mobile
viewport: the transcript scroller and the message bubble itself never
need a horizontal scrollbar to show either run (scrollWidth <=
clientWidth, 1px tolerance).
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
* test(e2e): trim commentary, assert real inline-code rendering
Review feedback: drop the implementation-essay docstrings (root cause
already lives in the source commit, not the test) and assert the long
token actually rendered as a markdown inline-code element, not just as
text somewhere in the bubble. Geometry checks and the plain-text
presence assertion are unchanged.
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
* docs(pr): trim verbose comments/docstrings to one sentence each
Comment/docstring-only cleanup across this PR's touched files. Removes
implementation-history essays and redundant comments where the name
or assertion already explains the code; shortens what remains to one
sentence. No production code, selectors, classes, assertions, test
names, or behavior changed.
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
---------
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
* fix(bench): don't require gateway creds to --live an own_auth native harness
The harness bench's --live mode required an OpenAI-compatible gateway
(Databricks by default) before it would run ANY native harness — even the
own_auth ones (agy, cursor, goose, kiro, qwen) that authenticate their own
model. Those resolved creds are only consumed to route the vendor's model
when `not vendor.own_auth` (native_tui_driver.py:284), so an own_auth native
never used them, yet `unavailable()` and `_provision()` demanded them
unconditionally. An external contributor with no Databricks account was
therefore unable to bench-verify the own_auth native they added — which is
exactly what happened on #3890, where the author had to patch the sources to
produce a matrix.
Thread `require_gateway=not vendor.own_auth` through `bench_creds_skip_reason`
and `resolve_bench_env`: an own_auth native no longer skips for missing creds,
and boots the server with no OPENAI_* (the gateway is resolved lazily with a
placeholder key, so a native-tui turn that never routes through it is
unaffected). A resolvable gateway is still used when present, and
omnigent-credential natives (claude-native, codex-native) keep failing loud on
no creds.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(bench): correct own_auth docstring example (cursor, not codex)
Polly review: the resolve_bench_env docstring listed codex as an example
own_auth native, but codex-native is OMNIGENT_CREDENTIAL (own_auth=False) —
it's on the gateway-REQUIRED side of this change. Use cursor, which is
genuinely own_auth, matching the PR summary and the ELI5 diagram.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
## Related issue
N/A
## Summary
- Clarify that task completion instructions should prioritize verification best performed by a human.
- Give concrete manual behavior checks as the preferred example instead of only unit test commands.
## Test Plan
- Reviewed the updated `Finishing a task` guidance in `AGENTS.md` for clarity and consistency with the surrounding instructions.
## Demo
N/A
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [x] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Documentation-only change; manually reviewed the rendered Markdown wording and surrounding section.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(claude-native): verify the switch-dialog confirm Enter actually landed
The dialog-confirm watch presses Enter once when the /effort - /model
dialog renders and assumes it took. Under the same busy repaint that
delays the dialog ~1.9s, the TUI can drop that keystroke: the dialog
stays parked, the composer never returns, and every later delivery
fails the readiness gate with 'input prompt never rendered'.
After a matched-hint Enter, poll that the dialog actually left the
pane and re-press while it verifiably remains, spaced so a slow but
successful dismiss is not double-tapped. An empty capture is a torn
read under that same repaint, so it keeps the retry alive instead of
being mistaken for the dialog closing. Retries fire only while the
dialog is on screen, so none can leak onto the returned composer.
Live-verified against a real Claude Code 2.1.220 pane: the dialog is
detected on render, accepted once, and the follow-up message delivers
where it previously wedged for 30s.
Signed-off-by: Jonathan Carter <42900403+joncarter1@users.noreply.github.com>
* fix(claude-native): disable Claude Code feedback surveys in wrapped panes
Claude Code periodically renders in-TUI feedback prompts ('How is
Claude doing this session?', the memory-recollection rating, the
transcript-sharing follow-up). They exist only in the pane, so a
web-driven session shows an unanswerable prompt - often with nobody
attached to the terminal at all.
Set CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 in the shared terminal env
builder, which every launch path uses (local wrapper, runner-spawned
web sessions, background-title runs). The CLI gates every survey
variant on this env var, checked ahead of even its internal force
flag. Standalone claude sessions outside the wrapper are unaffected.
Same decision as the agy survey disable for antigravity-native
(#1494): vendor TUI surveys are suppressed where the pane is not the
user's surface - though unlike agy's, this one broke nothing; it is
noise removal, not a turn-loss fix.
Signed-off-by: Jonathan Carter <42900403+joncarter1@users.noreply.github.com>
* fix(claude-native): verify slash-command delivery before trusting it
inject_slash_command typed the command and fired Enter blind, while
its sibling inject_user_message earned commit-polling and submit
verification from two prior production bugs. The same TUI applies the
same coalescing to both paths: an Enter consumed mid-burst folds into
the draft as a newline and the command sits unsubmitted. For /effort
and /model that failure is silent state divergence - the session row
persists the new value while the pane keeps running the old one - and
the next injection's C-u clears the drafted command, destroying the
evidence.
Reuse the message path's contract: wait for the typed command to
visibly land in the composer before Enter, then verify it left the
box, re-pressing only while it verifiably remains. A draft that never
becomes identifiable falls through to the old blind submit, and one
that never leaves raises so the runner returns an honest 503 instead
of reporting a switch that did not happen. The confirm-dialog watch
runs after delivery is proven, each stage gating the next.
Signed-off-by: Jonathan Carter <42900403+joncarter1@users.noreply.github.com>
* fix(server): give /compact forwards the TUI-injector budget
The claude-native compact handler now drives a delivery-verified
slash-command inject, whose fail-soft path alone can exceed the
default 5s forward budget. A timeout there reads as 'runner did not
handle it' and falls through to AP-side in-process compaction while
the runner's tmux /compact still completes - the double compaction
the fallthrough comment warns against. Effort and model forwards
already use the TUI budget; compact was the straggler.
Signed-off-by: Jonathan Carter <42900403+joncarter1@users.noreply.github.com>
* test(claude-native): cover the confirm retry's bound, blind submit, and pane recovery
Three gaps in the new verified-delivery coverage:
- Both confirm-retry tests close the dialog on the second Enter, so a
dialog that never closes is untested — an unbounded retry (dropped
deadline, or a torn-capture guard that never accepts a clean frame)
would spin forever on the injection thread with the suite still green.
Asserts the give-up happens inside the accept budget.
- The draft_seen=False fail-soft path had no test: a pane that never
renders the typed command must submit blind exactly once rather than
raise, or sessions with an unreadable composer break.
- Nothing tied the two verified stages to the reported symptom. Drives a
full effort switch whose confirm Enter is swallowed and asserts
claude_pane_ready, the gate that failed for 30s per message.
Also adds _confirm_and_verify_dialog_closed to the switch-path guard: the
confirm retry loop moved into it, so the no-fixed-sleep invariant should
follow it there.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: Jonathan Carter <42900403+joncarter1@users.noreply.github.com>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
bump-main had no job-level `if:`, so it defaulted to `success()` and
inherited the skipped benchmark jobs transitively through `cut`. Across the
last twelve release runs it fired exactly once: the only run where
benchmark-approve itself succeeded. Every other cut, including v0.9.0, left
main frozen on the version that had just shipped and needed a hand-run
bump-version dispatch.
Gate on `cut` succeeding instead, with the same `!cancelled()` opt-out `cut`
already uses. The in-job shell gates (dry_run, branch_exists, version
ordering) are unchanged; they just get a chance to run now.
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
The zygote is spawned as `python -m omnigent.runner._zygote`, which puts the
daemon's cwd on sys.path. A daemon started from a directory holding an
`omnigent` checkout (e.g. `omni host` from $HOME) binds the top-level name to a
namespace package whose __file__ is None, so _disk_build_stamp() raised
TypeError and killed the zygote at boot before it served a single fork.
Derive the package directory from this module's own location instead, which is
correct however the top-level name resolved.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(stores): resolve a session-scoped agent to its spawn-tree root
Named sys_session_send children are created bound to the same agent_id as their
mint, so _session_id_for_agent's unordered LIMIT 1 over conversations could
return a child row. The owning-session auth check then ran against a row not yet
visible on a read replica, surfacing as a spurious 404.
Select root_conversation_id instead of id. Every conversation sharing a
session-scoped agent's agent_id — the mint and all its named children — carries
the same root, so the unordered LIMIT 1 becomes unambiguous and stays O(1): there
is no wrong row to return. Authorizing on the root is not a behavior change,
because check_session_access already walks parent_conversation_id to the root and
grants on the root's ACL; for a top-level agent the root is the mint itself. It
also sidesteps replica lag, since the root is the oldest node in the tree rather
than a just-written child.
This covers the child-minted case the reverted parent_conversation_id IS NULL
approach got wrong by returning None and skipping the auth check, and needs no
scan, no cross-DB read, and no migration.
Fixes both get() and update().
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the named sub-agent 404 from the caller's side
The store-level tests pin the reverse lookup, but nothing exercised what the
user actually hit. These drive the real POST /v1/sessions calls a named
sys_session_send makes, with auth on and reads served by a store that models a
lagging read replica -- the two conditions the failure needs, which is why it
never showed up against a default local server.
test_later_named_sends_survive_unreplicated_sibling_rows fails on the
unordered LIMIT 1 with the reported 404 Conversation not found, and passes once
the agent resolves to its spawn-tree root. Its sibling row uses a pinned low id
so the pre-fix lookup selects it deterministically; left to chance it picks the
mint about half the time, which is why the symptom looked intermittent.
test_bundled_agent_uploaded_as_child_stays_private covers the other direction:
for a bundle uploaded into an existing session, the parent_conversation_id IS
NULL approach resolved no owning session at all, silently skipping the
owning-session check so an outsider could bind to a private agent. It asserts
the outsider gets 404.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
## Related issue
N/A
## Summary
- Avoid prompting development builds such as `0.9.0.dev0` to install the matching `0.9.0` final release.
- Continue notifying development builds about later release lines and post-releases.
## Test Plan
- `uv run pytest tests/cli/test_update_check.py::test_wheel_check_no_nag_for_matching_dev_release tests/cli/test_update_check.py::test_wheel_check_nags_when_newer_release_available tests/cli/test_update_check.py::test_is_newer_pep440_ordering tests/cli/test_update_check.py::test_is_newer_tolerates_garbage tests/cli/test_update_check.py::test_should_notify_release_treats_dev_build_as_current_release`
- `uv run ruff format --check omnigent/update_check.py tests/cli/test_update_check.py`
- `uv run ruff check omnigent/update_check.py tests/cli/test_update_check.py`
## Demo
N/A
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
The focused wheel-notice test reproduces the `0.9.0.dev0` versus `0.9.0` scenario, while helper coverage verifies later releases still notify.
## Changelog
Development builds no longer show an update reminder for the matching final release.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(runner): stop warning "sub-agent did not resolve" on healthy children
A sub-agent turn re-searched the session's cached spec for the sub-agent
name. By then the cache already holds the swapped CHILD spec (POST
/v1/sessions, a resource read, or an earlier turn all cache it), and
_find_spec_by_name only walks spec.sub_agents — so the lookup always
missed and every turn of a perfectly resolved child logged
Sub-agent 'pi' ... did not resolve in the parent spec; falling back
to the parent spec (child runs with the parent's prompt, tools and
harness).
The warning names a real silent failure — a child booting as a clone of
an orchestrator parent — so firing it on healthy sessions buried the
genuine case. Skip the swap when the spec in hand is already the child
(its name is the sub-agent name); an unresolvable name still warns.
Regression coverage: tests/runner/test_subagent_spec_swap_warning.py
asserts a declared sub-agent's turn logs no such warning and still
spawns the child's own harness, plus a negative control proving an
undeclared name keeps warning.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
* fix(runner): gate the sub-agent warning, not the spec swap
The previous commit skipped the swap whenever the spec in hand was named
for the sub-agent. That assumed a parent's name can never equal its
child's, which is false: `_check_unique_sub_agent_names` seeds `seen`
empty and walks only `spec.sub_agents`, so the root's own name is never
compared and a tree with root name == sub-agent name validates clean.
In such a tree `_find_spec_by_name` still resolves the CHILD, so the
shortcut skipped a swap that would have succeeded — booting the child
with the parent's prompt, tools and harness, and silently, since the
shortcut also suppressed the warning that exists to catch exactly that.
For a coordinator parent the clone re-dispatches into itself.
Restore the original swap: look the sub-agent up unconditionally and
swap whenever it resolves, so swap behaviour is byte-for-byte what it
was in every tree. Only the warning is gated, and only on a miss where
the spec in hand already carries the sub-agent's name — a state that
means the cache holds the child, not that a parent fallback happened.
Regression case: test_sub_agent_sharing_the_parent_name_still_swaps_to_
the_child drives a turn with no primed cache against a root/child name
collision and asserts the child's own harness is spawned with no
warning. It fails against the previous guard (spawns claude-sdk).
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
* docs(tests): describe the shipped sub-agent warning gate accurately
The module docstring still described gating the LOOKUP on the spec's name,
which is the shape that silently skips a legitimate swap when a root shares
its sub-agent's name. Describe what the code does: look the sub-agent up
unconditionally, swap whenever it resolves, and suppress only the warning on
a miss where the spec in hand already carries the sub-agent's name.
Also name the root/child same-name trap the third test pins, so a reader
learns why the naive name check is unsafe rather than "restoring
consistency" back to it.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
---------
Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
## Related issue
N/A — `Refactor / chore`.
## Summary
The PWA landed as one squashed PR (`b6976c1b2`, #116) whose headline was
installability. #116 was authored around mid-June, when "installable Omnigent on
mobile" was an open problem; it merged 2026-06-30, by which point the iOS shell
had shipped (#965, 2026-06-22) and the Android shell landed the next day
(#1604/#1704). The native shells took over the installed-app story while the PR
was in flight, and the PWA was never re-evaluated.
What was left was load-bearing for one thing only — the "new version → Reload"
prompt — and inert for everything else:
- `web/src` had zero uses of `navigator.serviceWorker`, `caches.*`,
`BroadcastChannel`, `pushManager`, `backgroundSync` or `setAppBadge`.
Notifications deliberately bypass the worker
(`web/src/lib/browserNotifications.ts`) and badges go through `nativeBridge.ts`.
- `version.json` was emitted, precached, and read by nobody.
- Installability was unadvertised (no `beforeinstallprompt`) and unmeasured (no
`display-mode` checks), so the worker's one cache entry existed only to satisfy
Chrome's "non-empty fetch handler" install heuristic.
Web Push (#1751, P2) is the only thing that would need a worker again, and a push
worker needs different handlers, VAPID keys and server infra — the retired file
is not useful groundwork.
ELI5: the service worker was a doorbell that only rang to say "the app has been
updated". Nothing else used it, and three native apps now do the "install
Omnigent" job it was built for, so the doorbell and its wiring come out.
A worker already registered in a browser stays registered after we stop shipping
one, so `sw.js` becomes a tombstone that removes itself:
```
deploy 0.10.0
│
▼
browser fetches /sw.js (no-cache) → installs tombstone → parks in `waiting`
│
├─ old tab still runs old JS, shows its own update banner one last time
│ user clicks Reload → SKIP_WAITING → activate
│ ├─ purge omnigent-pwa-* caches
│ └─ registration.unregister()
│ → tab reloads, PWA-free
└─ or all tabs close → activate on next visit → same cleanup, no prompt
```
Deliberately no `skipWaiting()` on install, so nobody's agent session is
interrupted by an unprompted reload. The purge matches the retired worker's exact
cache-name shape, `/^omnigent-pwa-[0-9a-f]{8}$/` — it only ever created
`omnigent-pwa-${(hash >>> 0).toString(16).padStart(8, "0")}` — rather than
clearing Cache Storage wholesale or trusting a bare prefix, so a tombstone
lingering in some browser cannot delete a future feature's caches even if that
feature reuses the prefix.
`registration.unregister()` leaves no persistent browser state, so registering a
worker at `/sw.js` again later is clean. Two things are kept for that reason:
the `no-cache` header for `sw.js` in `app.py` (so a cached tombstone can never
shadow a future worker) and the embed-island guard that forbids shipping any
service worker into a host origin.
Tombstone deletion is targeted at **0.11.0** (marked `@deprecated` in
`web/sw-src/sw.js` and in the vite plugin).
Not in this PR: `emptyOutDir: true` deletes old hashed chunks on deploy, the app
lazy-loads most routes, and there is no `ErrorBoundary` anywhere in `web/src`, so
a tab left open across a deploy can white-screen on navigation to a lazy route.
The prompt was a proactive nudge, never a guard — it never prevented the 404. The
gap pre-dates this change (it already applied to anyone who dismissed the banner)
and the fix (ErrorBoundary + reload on failed dynamic import) is independent of
the PWA, so it is filed separately.
## Test Plan
- `pnpm --filter web run type-check`, `run lint`, `run build` — clean; build
output contains `sw.js` only, with no `manifest.webmanifest`, no
`version.json` and no `pwa-*.png` (`apple-touch-icon.png` / `favicon.svg`
retained).
- `uv run pytest tests/server/integration/test_app.py::test_web_ui_serves_service_worker_uncached`
— passes.
- `pnpm exec vitest run src/components/UpdateBanner.test.tsx` — 5 passed;
confirms the similarly-named Electron desktop update banner is untouched.
- Exercised the rewritten build guard against the real build output, plus eight
negative cases, to prove it is not vacuous: a worker that calls `respondWith`,
an unscoped cache purge, a *bare-prefix* cache filter, a worker that never
unregisters, a stale `__BUILD_VERSION__` token, a re-emitted manifest, a
re-emitted `version.json`, and a missing `sw.js` are each rejected.
- Round-tripped the anchored cache pattern against the fingerprints the retired
worker could produce (uint32 min, max and typical values all render as 8
lowercase hex chars) to confirm the tightened filter still purges every legacy
cache name, while leaving unrelated names in the same namespace alone.
- `uv run pre-commit run` — all hooks pass.
## Demo
N/A — the only visible effect is the absence of the update banner.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [x] Integration tests added / updated
- [x] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
`tests/e2e_ui/test_pwa_e2e.py` is deleted (it asserted live PWA behaviour) and
`conftest._assert_pwa_build` is replaced by
`_assert_service_worker_tombstone`, which now enforces the *dangerous*
direction: the worker must unregister itself, must intercept nothing, must not
purge caches it does not own, and the manifest/version sentinel must be gone.
`tests/e2e_ui/test_pwa_build.py` is renamed to `test_embed_service_worker.py` and
kept — "the embed island ships no service worker" outlives the PWA.
Manual verification covered the parts a test cannot: the emitted build output was
inspected by hand, and the guard was run against both the real output and seven
mutated inputs (listed in the Test Plan) to confirm each regression is caught.
The deleted unit tests covered only the removed components.
## Changelog
Removed the "A new version of Omnigent is available" prompt and browser PWA
install support; the desktop and mobile apps remain the installable clients.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
[OMNI-2505](https://linear.app/omnigent/issue/OMNI-2505/move-otel-dependencies-into-extra)
## Summary
- Keep the default installation lean by moving OTLP exporters and automatic instrumentors into `omnigent[tracing]`.
- Retain the lightweight OpenTelemetry API in the base package for server performance metrics and declare the SDK directly in tracing-enabled installs.
- Preserve tracing dependencies in internal `all` installs and Databricks deployments.
- Mark the optional OpenTelemetry SDK, exporter, and instrumentation namespaces in Pyrefly rather than installing them through `dev`.
## Test Plan
- `uv run pytest tests/runtime/test_telemetry.py`
- Verified a bare isolated installation imports `omnigent.server.app`.
- Verified an isolated `--extra tracing` installation imports the SDK, exporters, and FastAPI, HTTPX, and SQLAlchemy instrumentors.
- `uv run --frozen pre-commit run --files pyproject.toml uv.lock deploy/databricks/deploy.py`
- `uv run --isolated --frozen --extra dev pre-commit run pyrefly --all-files`
## Demo
N/A — dependency metadata only.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Validated both dependency modes in isolated environments: the base install can import the server, while the tracing extra provides every exporter and instrumentor used by telemetry initialization. The existing telemetry unit suite covers runtime behavior.
## Changelog
OpenTelemetry exporters and automatic instrumentors are now installed through `omnigent[tracing]` instead of the default package.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
omnidev assumes the OSS repo layout (an `omnigent/` backend + `web/` frontend
rooted at the repo root). Add a `--profile <toml>` flag so it can supervise an
Omnigent integration embedded in a repo with a different layout — e.g. a
server, Vite UI, and compatibility host that live at arbitrary paths and are
launched by custom commands.
The profile is a TOML file describing the server / vite / optional
prepare / optional host process commands (with runtime placeholder
expansion), the backend and web directories, and the dependency manifests to
watch. When --profile is set, find_repo_root skips the OSS layout check
(the integration's root need not contain omnigent/ + web/) and Pod is built
via create_with_profile from the profile's process specs instead of the
built-in OSS defaults.
Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>
## Related issue
N/A — release chore.
## Summary
- Bumps `MARKETING_VERSION` from 0.1.1 to 0.1.2 for the `ai.omnigent.ios` target
(Debug and Release) ahead of a TestFlight build, so testers can tell the build
carrying the workspace fixes apart from earlier 0.1.1 uploads.
- Covers two user-facing iOS fixes now on main: connecting to a Databricks
workspace opens Omnigent directly and hides the workspace nav bar (#4559), and
the top controls no longer render under the status bar on workspace-hosted
servers (#4568).
- Only the app target moves. The `.tests` / `.uitests` bundles stay at 0.1.0 —
they are never shipped, and `web/ios/RELEASE.md` scopes manual bumps to the
Omnigent target.
- `CURRENT_PROJECT_VERSION` is deliberately untouched: the `beta` lane computes
the build number as `latest_testflight_build_number + 1` and injects it via an
xcodebuild override, so bumping it in git would only add churn.
- Not part of the repo-wide version lockstep: `scripts/update_versions.py` covers
the Python packages and the Electron desktop app, not the iOS project.
## Test Plan
- `xcodebuild -project web/ios/Omnigent.xcodeproj -target Omnigent
-showBuildSettings -configuration Release` reports `MARKETING_VERSION = 0.1.2`
and `PRODUCT_BUNDLE_IDENTIFIER = ai.omnigent.ios`, confirming the resolved
setting rather than just the edited text.
- `python scripts/update_versions.py check` is unaffected (iOS is not one of the
locked locations).
- `pre-commit run --files web/ios/Omnigent.xcodeproj/project.pbxproj` clean.
## Demo
N/A — version metadata only, no UI change.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [x] Not applicable
## Coverage notes
Build-setting metadata with no runtime behaviour, so there is nothing to unit
test. Verified by reading back the resolved `MARKETING_VERSION` from
`xcodebuild -showBuildSettings` for the Release configuration of the shipping
target.
## Changelog
N/A
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(antigravity): make the agy harness usable from the web UI
Running agy through Omnigent lost most of what agy was doing. This
brings the web UI to parity with what the terminal already showed.
Every fix below was found by reading live agy RPC traffic and verified
against real sessions; the recorded frames are checked in as fixtures.
**Plugin skills were missing.** An omnigent-spawned agy gets an isolated
`--gemini_dir`, and nothing seeded the user's plugins into it, so
`agy plugin list` was empty under Omnigent while identical outside it.
The bridge now symlinks `config/plugins` and copies `import_manifest.json`.
**The slash menu offered Claude's skills.** The skill-source registry
had no antigravity family, so agy sessions fell through to the
claude-native provider. agy now has its own five sources, with plugin
skills namespaced `<plugin>:<skill>` and enabled only when `plugin.json`
is present.
**`--dangerously-skip-permissions` was unreachable.** claude-code exposes
its bypass in the new-chat dialog; agy had no equivalent, so the flag
could only be set by hand-editing launch args. Added as a capability with
the same danger banner.
**Sub-agents forked duplicate top-level sessions.** agy spawns each
sub-agent as its own cascade, and a working sub-agent is always more
recently active than the parent idling behind it — so the rotation
detector read every spawn as a `/clear` and dragged the pane onto the
child. Children are now identified by `trajectoryMetadata` and skipped.
**Cold start could bind a stranger's agy.** With several agy processes
alive, a session could attach to another one's RPC port and mirror its
conversation. Ownership is now confirmed after `StartCascade`. Port
attribution also moved from shelling out to `lsof` — an undeclared
dependency absent from many images, and unavailable on Windows — to
psutil, which is already a dependency, with a `/proc/net/tcp` fallback.
**Replies duplicated and truncated.** The streaming reader stamped a
constant `"index": 0` on every text delta, and the server discards any
chunk whose index does not advance — so the first chunk rendered, the
rest were dropped, and the unretired buffer replayed to later
subscribers. Deltas now carry a real index, and the live block is closed
on both the stream and poll paths.
**No tool call was ever mirrored.** agy serves each step at two
fidelities: the snapshot RPC carries `metadata.toolCall` and
`plannerResponse.toolCalls`, while the live stream strips both (each
embeds a `thinkingSignature` blob). The mapper was built against the
snapshot, so streamed turns recorded 611 tool outputs against 0
invocations — naked result blobs, most keyed to invented `_orphan_N`
ids, with `view_file` and `invoke_subagent` results dropped entirely.
Both items now derive from the result step, which both shapes deliver in
full, keyed on its own `(trajectory, step)` identity so a stream->poll
fallback cannot re-key a pair.
**Sub-agent work was invisible.** agy names each sub-agent's cascade,
role and type on the parent's `INVOKE_SUBAGENT` step, but nothing
mirrored them, so a four-reviewer dispatch showed one opaque tool call
and an empty Agents rail. Each child now gets a child session and a
mirror loop. `invoke_subagent` is fire-and-forget — its step reaches DONE
while the child runs on for minutes — so each mirror ends on its own
child's turn closing, with agy's run status as the backstop for a turn
that never closes.
Test plan:
- 730 passed, 1 skipped across the antigravity selection; pre-commit clean.
- 6 stream-projection fixtures are verbatim live frames — the shape that
had no coverage, which is why the tool-call bug shipped.
- Every fix verified end-to-end against a live agy: `agy plugin list`
A/B, the `/skills` panel, live SSE captures for the delta index, and a
replay of the real conversations for tool calls (18 tool steps -> 18
complete pairs, both RPC shapes agreeing) and sub-agents (children that
had recorded 1 item each now mirror their full transcripts and close).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* test(e2e-ui): cover agy's permission toggle with Playwright
The E2E UI Required gate rejected the PR: the new-chat dialog gained
agy's permission control with only Vitest coverage under web/, and no
Playwright test exercising it. The gate is right — this is the toggle
that arms `--dangerously-skip-permissions`, and the repo requires a UI
test for user-facing UI changes.
Two tests, driving a real browser against the stubbed landing picker:
* arming the bypass raises the red danger banner and rides along to
`POST /v1/sessions` as
`terminal_launch_args: ["--dangerously-skip-permissions"]`;
* leaving it alone sends NO launch args, so a session cannot silently
inherit the bypass the user never chose.
The banner assertion is the point of the first test as much as the flag
is. agy fires no pre-tool hook, so once the bypass is armed Omnigent
cannot re-gate individual tools — the warning is the only thing between
the user and an agent that edits any file and runs any command without
asking. The test also asserts the banner is ABSENT before opting in, so
it cannot decay into permanent furniture that users learn to ignore.
Both reuse the module's existing `_antigravity_native_agents_body`
stub rather than adding a second one.
Test plan:
- Both pass in a real chromium run (2 passed), and the whole
`test_start_session.py` file passes (22 passed).
- Each assertion verified to bite: emptying the flag's `args` fails the
launch-args assertion, and suppressing the banner fails the visibility
assertion.
- pre-commit clean.
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(antigravity): address PR review on the agy harness
Follow-up to the agy harness work, resolving reviewer feedback on #3890.
- Bump the psutil floor to >=6: the connect-RPC port discovery calls
Process.net_connections(), which 5.9 spells connections(). The
AttributeError there is neither psutil.Error nor OSError, so it escaped
the fallback instead of degrading to lsof.
- Seed the Global and Shared agy skill trees into the isolated Gemini dir
alongside plugins, so the /skills menu cannot offer a skill agy would
fail to expand. The other two sources need nothing: agy recreates its
builtins under any --gemini_dir, and the workspace tree is not under it.
- Take a sub-agent's own nested mirrors down with it: a child's steps run
the same path as the parent's, so a nested INVOKE_SUBAGENT registered a
grandchild the reader's teardown drain never walked.
- Back the sub-agent quiescence window off after each veto instead of
resetting it flat. agy answering "still running" can only veto the
close, so a flat window re-asked every minute for the whole session.
- Fix two comments still attributing child exclusion to trajectoryType,
which a subagent reports byte-identically to a root.
- Use a per-step chunk counter for planner delta indices. The forwarded
byte offset moves backwards on a shorter post-moderation rewrite, and
the server drops any chunk that does not outrank the last accepted one,
so the closing final chunk was discarded and the block never closed.
- Prefer an exact match before the prefix scan in _arguments_from_body so
a suffixed sibling key cannot shadow the argument that was asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(sessions): import the agy sub-agent symbols explicitly
The sub-agent start path resolved its symbols through the sessions
wildcard imports, which main has since replaced with explicit blocks. The
references now fall through to NameError on the first
external_antigravity_subagent_start event.
Import each symbol from its owning module, matching how the codex
equivalents are already listed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(antigravity): catch AttributeError from psutil's pre-6.0 connections API
The connect-RPC port discovery calls Process.net_connections(), the psutil
6.0 rename of connections(). The dependency floor still admits 5.9, where
the attribute is simply absent — and an AttributeError is neither a
psutil.Error nor an OSError, so it escaped the fallback instead of
degrading to lsof, which the docstring already promised.
Widen the except rather than raising the floor. Both say "psutil discovery
does not work on 5.9", but this one says it in code and leaves pyproject
and uv.lock byte-identical to main: the lockfile edit was the sole trigger
for the OSV advisory scan, which then blocked the whole pipeline on
cryptography advisories inherited from main's baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(antigravity): keep streamed tool turns running and label agy sub-agents
Addresses the second review pass on #3890.
- Key the turn-close edge on assistant text rather than the absence of
plannerResponse.toolCalls. The stream strips that field, so a streamed
tool dispatch (DONE, no toolCalls, no text) was read as a degenerate
close and fired IDLE the moment agy called a tool — the spinner cleared
mid-turn and RUNNING could not re-open. Text is the only discriminator
that holds in both RPC shapes; a genuinely degenerate turn is now
reconciled by the existing idle backstop instead.
- Register antigravity's sub-agent wrapper so the Agents rail renders the
child's role instead of the cascade UUID. The label also feeds the chat
header and composer, which were falling back to the internal agent name.
- Advance the planner delta prefix tracker only when a delta is actually
emitted. It records what the server received, so re-anchoring it on a
frame that emitted nothing cut the next delta from the wrong offset and
duplicated text in the live block. Left the reasoning sibling's
unconditional re-anchor alone — it has no committed close to flush the
remainder — and corrected its comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
---------
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session.input.consumed committed-item branch added in #3595 drops the
FIFO-head pending entry unconditionally when clearedPendingId is unset.
Claude's own `[Request interrupted by user]` record owns no pending entry
and is published with clearedPendingId unset, so when a snapshot merge
commits it into blocks before its consumed event arrives, this branch
pops a real queued message's optimistic bubble instead — the user's
in-flight bubble disappears until the message round-trips.
Mirror the sibling promote path: drop the named entry when
clearedPendingId matches, otherwise hold the FIFO head back for a system
marker (isSystemUserContent guard). Add the missing regression test — an
interrupt marker snapshot-merged into blocks with a real message at the
pending head, which must survive.
Co-authored-by: Isaac
* fix(claude-native): make Claude's status file the source of truth
Claude's `sessions/<pid>.json` reports what Claude is doing; the tmux pane
diff only infers it from redraws. Both were publishing session status, and
union (either source asserts it), `idle` an intersection (both must agree,
via a 10s `asserts_running` freshness window). You could not state what a
session's status *was* without replaying which edge landed last, and the
window let a `SIGKILL`ed Claude parked on a permission prompt pin the
spinner forever: `waiting` was exempt from the TTL, and the poller only
retires when the file *vanishes*, which a killed process never does.
The file now decides while it is readable. Precedence is one rule: the
file, unless no file resolved (Claude < v2.1.139), unless the pane is dead.
- resource_registry: the pane publishes no status while the poller is
active — it keeps the activity badge and owns pane death. Deletes
`_blocked_reason` and the freshness-window constant.
- status_file: `asserts_running` is gone; a new `retire()` is called from
the watcher's exit path, since a killed Claude leaves its record behind
holding a value that would otherwise keep owning the session.
- forwarder: `Stop` no longer decides status. It carries the two things
the file cannot express — the background-shell count (its `shell`
literal is a boolean; the indicator renders a number) and the sub-agent
delivery edge. `StopFailure` stays: the file has no failure literal, so
it is the only source of the red pill and a failed scheduled run.
- Ordering stopped mattering: `Stop`'s idle and the file's idle are the
same edge and share a dedup baseline, so whichever lands second is
collapsed. One idle reaches the client, no flicker.
This removes the `waiting` relabel at its source, where #4266 normalized
it at server ingress. That normalization stays — it covers runners that
predate this change and still post `waiting`.
Also stop publishing status as a control signal. Policy-deny and
`/compact` bracketed themselves with synthetic `running`→`idle` pairs, so
a denied tool call reported a turn that never ran — and its stray idle
folded a live turn's bubble mid-stream. The terminal `response.completed`
already unblocks live-tail consumers and the compaction bubble owns its
own spinner. With the cause gone, `reviveStrayCompletedResponse` — the
client-side hack that flipped `sessionStatus` back to `running` on the
next delta — goes too. The web client also stops forging
`sessionStatus: "failed"` when its own stream fails to open: losing our
stream says nothing about what the agent is doing.
No other harness changes behaviour — the poller is claude-native only, so
`_file_owns_status()` is always false for the seven other PTY-watched
roles and they publish exactly as before.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(claude-native): stop the transcript forwarder publishing session status
#4344 made Claude's `sessions/<pid>.json` the source of truth for
claude-native running/idle, but missed a publisher: the transcript
forwarder still posted `running` when it first saw a turn's assistant
output. That produced a visible flicker on every short turn —
session.status idle <- the file; the turn really ended
session.status running <- the transcript forwarder, late
session.status idle <- Stop
because the file flips the instant Claude settles, while a
transcript-derived edge can only fire once a poll has parsed assistant
output. It lands after the file's `idle` and re-asserts `running` on a
session that already finished.
That POST never existed to report status. #1499 added it to carry
`response_id` so the web store opens a streaming `activeResponse`; it
carried `running` only because `_publish_status` gates the id on it. Same
shape as the policy-deny and `/compact` pairs #4344 removed: a
bubble-lifecycle signal multiplexed onto `session.status`.
Deleting it needs nothing in its place. The items are a separate POST
(`external_conversation_item`) and already carry their own `response_id`,
so they still forward and still group. `posted_running_response_id` and
`_turn_has_assistant_output` become dead and go with it.
Accepted cost: `activeResponse.state === "streaming"` is now unreachable
for claude-native on the live path, so a tool call renders `no-output`
rather than `input-available` between dispatch and result — no spinner in
that gap. Once the result lands, `output !== null` wins and the card
renders normally. This also preserves for free the property three tests
pin (`renderItems.test.ts:704`, `:720`, `:736`): a tool whose result never
arrives must not spin forever. A follow-up should derive tool liveness
from `sessionStatus` + newest-turn instead of `activeResponse`, which
restores the spinner and drops the turn-id dependency for good — deferred
because it touches the renderer every harness shares.
claude-native only. `_forward_available_items` has one entry point
(`forward_claude_transcript_to_session`); goose, hermes, and codex post
their own id-bearing `running` from their own forwarders, where it is
their only status source. `post_external_session_status` keeps its
signature and the web `session.status` handler stays generic, so those
harnesses are untouched (170 of their tests pass unchanged).
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): light the chat "Working…" indicator on send, like the sidebar
Pressing Enter sets `chatStore.status = "streaming"` synchronously, but
leaves `sessionStatus` alone — the two fields mean different things
("this client's send is in flight" vs "the server says the agent is
working"). The sidebar row opted into the local one and lights up
immediately (`isStartingUp` in Sidebar.tsx reads `s.status`); the chat
pane read only `sessionStatus`, so its spinner waited for the server's
`running` edge and the two surfaces disagreed for the whole dispatch
round-trip.
`computeShowsWorking` now takes `localSendInFlight` and treats it as
working. It also survives the `runnerOnline === false` gate for the same
reason a live running/waiting status does: sending to an asleep runner
relaunches it, and `/health` reads stale-offline during that window at
its 10s cadence. A pending elicitation still outranks it, so the prompt
and the shimmer never stack.
The flag is opt-in, so a cross-client or TUI-typed turn — which sets no
local status here — still shows nothing until the server speaks.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(runner): re-assert session status after the tunnel reconnects
A server restart mid-turn left the session with no working indicator and
no stop button for the rest of the turn.
The tunnel reconnecting usually means the *listener* restarted — a
deploy, a crash, a replica failover — which wipes the server's in-memory
`_session_status_cache`. This runner keeps running, so every dedup
baseline still asserts its last edge was delivered, and nothing
re-asserts on its own: Claude's `sessions/<pid>.json` is written only
when its value *changes*, and the pane watcher's edges are coalesced to
the idle->running transition. So the restarted server never learns the
session is running.
Nothing else covers it. The server's cache-miss fallback polls the
runner, but `GET /v1/sessions/{id}` derives status from `_active_turns`,
which is empty for native harnesses. And `_catch_up_scan` — the existing
`on_reconnect` hook — skips native harnesses outright.
`resource_registry.resync_session_statuses()` drops the published-edge
baselines so the next poll republishes the current value verbatim. The
claude-native pollers are re-armed too: they hold their own edge/mtime
baselines on the watcher thread, so clearing only the registry side would
leave them silent. The exit-classification memo (`_last_session_status`)
is deliberately untouched — it tracks what the PANE last did, not what
the server has heard, and clearing it would make a crash right after a
reconnect read as a clean shutdown. A retired poller stays retired, so a
reconnect can't hand status back to a dead Claude's leftover record.
Pre-existing, but recently more exposed: while the pane watcher published
`running` on every fresh redraw it papered over this within a second. Now
that the file owns the status, the file is the only publisher — and it has
nothing to say.
Also adds the first logging to `claude_native_status_file` (resolve hit,
resolve give-up, retire, resync). The module had none, so "did the poller
ever find the file?" was only answerable by re-deriving the resolution by
hand against a live session — which is exactly what diagnosing this took.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): let a spin-up keep the "Starting up…" cue over the shimmer
953187f9 lit the chat pane's "Working…" shimmer optimistically on send,
which took the in-thread slot that `RunnerStartingIndicator` used to own
(it renders only when the shimmer is absent). A send that has to boot a
runner then read "Working…" instead of "Starting up…" / "Cloning
repository…" — dropping the more specific copy at exactly the moment the
user needs it, since booting is the slow part.
`ChatPage` now stands the optimistic path down while a terminal-first
spin-up or a managed-sandbox launch stage is in flight. Only
`localSendInFlight` is gated: a server-confirmed `running`/`waiting`
still lights the shimmer, and by then the spin-up cue has self-gated to
null, so the turn is never left with no indicator at all.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): spin claude-native's in-flight tools off the session status
An in-flight tool card showed "No output" instead of a spinner for
claude-native. The spinner is gated on the bubble's lifecycle reaching
`streaming`, which is only reachable through a streaming `activeResponse`
— and claude-native never opens one: its running/idle lives in Claude's
status file (`sessionStatus`), the transcript forwarder no longer posts a
turn-start `running`, so no bubble is ever `streaming` and
`trailingLiveToolCallIds` returns nothing.
Widen the gate: the trailing tool phase spins when EITHER the bubble is
the streaming `activeResponse` (unchanged, in-process harnesses) OR the
session is running and the bubble is its newest turn. `buildBubbles` takes
a `sessionRunning` flag and computes the newest turn id
(`newestAssistantTurnId`, scanning back from the end); `ChatPage` passes
`computeIsWorking(sessionStatus)`. This is the same "last assistant bubble
+ session running" liveness `BlockRenderer` already uses to keep the trace
expanded, so the two agree.
`lifecycle` itself is untouched — fork, fold, cancelled, and failed all
read it as before, and the in-process harnesses are unaffected (the new
condition only ADDs the session-driven case). The property the three
never-spin tests pin is preserved: a settled turn — reloaded history, a
finished turn, a dead harness whose session reads idle — is neither
streaming nor the running session's newest turn, so a result-less tool
still resolves to `no-output`, never a perpetual spinner.
The one subtlety is the reuse cache: a running→idle flip carries no block
change, so `liveTurnId` joins the cache key and `reusablePrefix` refuses
to reuse a bubble matching the previous or current live turn — otherwise a
dangling tool would keep its stale spinner after the turn settled.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
---------
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
`omnigent setup` / `configure harnesses` installed the Claude CLI with
`npm install -g @anthropic-ai/claude-code`. On machines where npm's global
prefix is root-owned (the common default: `/usr/local`, or a system Node) this
fails with an EACCES permission error, and `sudo npm install -g` is exactly what
Anthropic's docs warn against.
Follow Anthropic's recommended path for Claude: the native installer
`curl -fsSL https://claude.ai/install.sh | bash`, which writes to a
user-writable ~/.local/bin and self-updates, so Omnigent never owns npm
global-prefix / PATH edge cases. Codex, Pi, Qwen and OpenCode keep the existing
`npm install -g` flow (they have no first-party native installer).
This needs no new plumbing: `HarnessInstallSpec` already models a vendor
installer, so Claude declares `install_hint` + `install_command` and drops
`package`, exactly as Hermes does. Dropping `package` is what keeps the rest of
the codebase honest: `harness_setup_hint` and the runner's missing-CLI error in
`tool_dispatch` both branch on `package is None` to name the vendor installer,
so neither can suggest the npm command that fails on a root-owned prefix.
The one addition is `harness_install_display`, because `harness_install_command`
wraps the installer as `bash -c <script>` for subprocess; joining that argv into
a setup menu would print the wrapper for the user to strip by hand. The helper
prefers the spec's `install_hint`, which also fixes the same display wart for
Hermes.
Refs: https://code.claude.com/docs/en/setup#native-install-recommended
Signed-off-by: Rohit Kewalramani <rohit.pk93@gmail.com>
#4539 gated the harness fork, but the zygote also forks whole runners and
that path has the same mixed-version bug. A forked child inherits the graph
imported at zygote boot yet resolves its lazily-imported modules from disk,
so once `uv tool install` rewrites site-packages under a running host:
File ".../omnigent/runner/_zygote.py", line 188, in _run_child
File ".../omnigent/runner/_entry.py", line 1142, in create_app
ModuleNotFoundError: No module named 'omnigent.cli_auth'
create_app imports omnigent.cli_auth lazily, and the swapped-out package
directory no longer serves it, so the forked runner dies at boot — the same
failure shape as the harness fork's missing describe_exception.
Lift the stamp check into _refuse_if_upgraded() and apply it to `fork` as
well as `fork_harness`, naming the child kind in the error so operator logs
say which launch fell back. The daemon already catches ZygoteUnavailable and
falls back to a direct Popen, which reads the new code coherently.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Two races strand a stale trailing element at the bottom of the
transcript on native-terminal sessions:
- session.input.consumed bailed on the committed-item guard without
dropping the matching pendingUserMessages entry, so when the
forwarder-mirrored user item beat the event into blocks (stream or
snapshot merge), the optimistic user bubble was never cleared and
rendered forever after the last committed block.
- The stream pump's generic item-id dedup ran before the native
live-preview replacement, so an authoritative text_done whose item a
snapshot merge had already inserted was skipped entirely, leaving the
live:* provisional preview rendered beside the real assistant text.
Clear the pending entry (named match, then FIFO) even when the item is
already committed, and retire the oldest live preview before the dedup
drops an already-committed authoritative item.
Signed-off-by: Adrian Lyjak <adrianlyjak@gmail.com>
Co-authored-by: Daniel Lok <daniel.lok@databricks.com>
* Browser zindex modal - suppress browserview
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* Attempt 2
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test: cover browser-view overlay suppression (#3980)
Add IPC handler coverage for omnigent:browser-set-suppressed (registration,
delegation, trust gate) and a renderer test for the SuppressBrowserView
ref-count (suppress on first mount, restore on last unmount, no-op without
the desktop bridge).
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test: fix oxlint dangling-underscore in browserIpc suppression test
CI's web-oxlint hook fails on warnings; `_entries` tripped the
no-dangling-underscore rule. Track the setSuppressed flags on a plain
`suppressedCalls` array on the stub registry instead.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* address feedback
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
---------
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* fix(runner): rebind the comment relay when a session's agent changes
The comment relay advertises a tool surface built from the session's
agent spec, but `_session_comment_relays` was keyed by session id alone
and `_ensure_comment_relay_started` returned early on that key before
resolving the current agent or bridge directory. No cache-clearing path
removed the entry, so after an agent switch the native harness kept
seeing the previous agent's surface: spec-gated families the new agent
never granted (`sys_terminal_*`), stale schemas for same-named tools
(`sys_session_send`'s sub-agent enum), and — when the switch reassigned
the bridge id — no relay in the new bridge directory at all.
Bind each relay to the spec entry and bridge directory it was built for.
A lookup now resolves the current spec first and reuses the relay only
when both still match; otherwise it starts a replacement, installs it,
and closes the superseded one. The session spec cache returns the same
object until an agent switch or update evicts it, so identity comparison
is enough and an unchanged session still short-circuits without paying a
bridge-id round trip.
Also key the bridge-injected launch-failure rollback on the relay
instance rather than the session id. It was gated on "was a relay
already present", which a leftover relay makes true, and removed by key,
which could drop a relay another path installed meanwhile.
Closes#3950
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): keep the serving relay when spec resolution fails
Resolution failing is not the same as a session resolving to no spec.
The lookup treated both as `spec_entry = None`, so a transient failure on
a session that already had a relay compared `None` against a real spec,
missed, and rebuilt on the minimal fallback surface — withdrawing
spec-gated tools the agent does grant until resolution recovered.
Keep the bound relay on the error path instead, restoring the behavior
the pre-fix early return gave for free. This is reachable from turn
startup, which tolerates an unresolved spec and calls through regardless;
the terminal-launch route resolves the spec itself and fails the request
first, so it never reaches this branch.
Also record why the cheap same-spec short-circuit may skip deriving the
bridge dir: a bridge id is only reassigned alongside the agent, and every
caller that can reassign it independently passes a bridge hint.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
record_hook_event wrote transcript_path/session_id into state.json from
any hook payload, so a side-channel event carrying another session's
identity (e.g. a background Task agent's edge) silently re-aimed the
transcript forwarder at a foreign file that may never grow — the same
blackout signature as a stalled forwarder. Identity fields now apply
only from SessionStart announcements (startup, /clear, resume, fork,
compact all fire one), events of the already-pinned session, or the
first identity-bearing event on a fresh bridge. Rejected events are
still recorded in hooks.jsonl; only their identity is ignored.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(server): derive launch args for kimi-native and antigravity-native sub-agents
Named sub-agent workers on the kimi-native and antigravity-native
harnesses launched with no autonomy flag, so every risky tool call
parked on a web approval card no headless pane can answer.
_derive_terminal_launch_args_from_spec only knew claude/codex/cursor
and fell through to None for both harnesses.
- kimi-native: executor.config yolo: true -> ["--yolo"] (kimi's
auto-approve-tools flag, matching codex/cursor semantics; --auto full
autonomy deliberately not mapped). Opt-in: absent/false unchanged.
- antigravity-native: executor.config permission_mode:
bypassPermissions -> ["--dangerously-skip-permissions"], agy's only
pre-emptive permission control. Other/absent modes unchanged. The
runner spawn path already forwards snapshot terminal_launch_args
verbatim into the agy argv (build_agy_launch extra_args), now pinned
by a spawn-path test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
* fix(server): harden kimi/agy launch-arg derivation and pin the runner replay seams
Round out the kimi-native / antigravity-native launch-arg derivation:
- Document the value-matching policy on the derivation helper: flag keys
(yolo) accept bool or case-insensitive true/false strings (mirroring
_spec_config_flag_explicitly_disabled); mode keys (permission_mode)
match exactly, mirroring the runner's should_skip_permissions
comparison. Debug-log a present-but-unrecognized value instead of
silently no-opping.
- Parametrized boundary tests pinning accepted-vs-rejected spellings for
both branches (bool True/False, "true"/"TRUE", YAML-1.1-style
yes/on/1 rejected; permission_mode exact-case only).
- Runner replay test proving a kimi-native session's stored
terminal_launch_args reach the launched kimi argv verbatim (the seam
the server-derived --yolo rides), mirroring the existing antigravity
extra_args replay test.
- Pin build_agy_launch's skip-flag dedup for the double-source case
(permission_mode=bypassPermissions + the flag already in extra_args
-> exactly one flag).
- Note the yolo / permission_mode pass-through semantics in the
ExecutorSpec.config contract docstring and widen the test module
docstrings to cover the kimi/antigravity branches.
Co-Authored-By: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
* refactor(server): dry-pass the native launch-arg derivation change
Trim the kimi/agy inline branch comments to short pointers — the
function docstring already carries the full per-harness policy and
value-matching contract — and drop four standalone tests whose inputs
are exactly covered by the parametrized spelling-boundary tests,
folding their unique rationale into those docstrings.
Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
* fix(server): drop whitespace leniency from kimi/agy launch-arg opt-ins
Tribunal round-1 on the kimi-native / antigravity-native derivation:
- A padded value must not enable a bypass flag (fail-closed): kimi's
yolo string is now matched case-insensitively without whitespace
tolerance, and agy's permission_mode is compared exactly against
"bypassPermissions" — matching the runner's should_skip_permissions
comparison so server and runner can never disagree on a padded value.
Flipped the " TRUE " / " bypassPermissions " boundary rows to expect
no args and updated the policy docstrings accordingly.
- Removed the build_agy_launch dedup test that pinned unchanged
upstream behavior this change does not touch.
- Widened the derivation docstring's harness enumeration to include
kimi-native / antigravity-native.
Co-Authored-By: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
* fix(server): fail-closed non-string permission_mode and accurate kimi log guard
Tribunal round-2 on the kimi-native / antigravity-native derivation:
- antigravity-native: executor.config is dict[str, Any], so a
non-string permission_mode with an overloaded __eq__ could enable
--dangerously-skip-permissions (or raise on comparison). Gate the
branch on isinstance(mode, str); non-string values debug-log and
leave args unset. Pinned by a fail-closed test using an
__eq__-answers-True object.
- kimi-native: bool False is a documented recognized value, so exclude
it from the unrecognized-yolo debug log.
- Qualify the value-matching docs: whitespace intolerance applies to
the enabling value (the opt-out side reuses the stripping helper).
Co-Authored-By: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
---------
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The transcript forwarder's poll iteration is a chain of awaits; each
known wait is individually bounded, but one unbounded or wedged await
anywhere froze the whole in-order pipeline forever with zero log output
(observed three times in one day: mirroring, status events and the pane
busy signal all dark for 34-60+ minutes, then the idle reaper killed the
live session). Wrap every iteration in asyncio.timeout(300s): a stall
now gets its await cancelled, a WARN whose traceback names the exact
stalled line, and the next iteration resumes. Safe to resume because
cursor state only advances after successful posts, so a cancelled step
is retried like any transient failure.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
A stopped forwarder takes mirroring, status events and the pane busy
signal with it, yet every exit path (cancel, escaped exception, clean
return) was silent — an hour-long session blackout left nothing to grep
for. Extend the registry's existing done-callback: cancellation logs
INFO, an escaped exception logs ERROR with the traceback (and retrieves
it, so it can't resurface as an unattributed 'Task exception was never
retrieved'), and an unexpected clean return warns.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The native pane reaper killed live, actively-working terminals when the
harness status pipeline silently stalled: every busy signal it consulted
(active Omnigent turns, forwarder-fed pane status, attached clients) is
derived state that can be false while the pane is demonstrably emitting
output. tmux stamps window_activity on every byte a pane emits, so the
busy check now also treats output within the last 120s (two scan
intervals) as busy — a producing terminal can no longer be reaped no
matter what breaks upstream, while a genuinely silent pane still reaps
on the normal schedule.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The dark-mode glassmorphism rule clears the workspace panel's background
to transparent so it blends into the canvas when docked. When the panel
is maximized (absolute inset-0), this lets the chat content underneath
bleed through.
Add a data-maximized attribute to the panel and gate the transparent
background rules with :not([data-maximized]). Apply an explicit
var(--card-solid) background when maximized so the panel is opaque
across all themes.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(routing): count managed-settings AIGW backing for claude-native
claude_gateway_inference_backed() returned False whenever
resolve_native_claude_config yielded no config — the case for a
subscription (Claude Code login) provider. But Claude Code itself still
routes all inference through an AI Gateway when an enterprise managed
settings file pins ANTHROPIC_BASE_URL, so Smart Routing was being gated
off for a genuinely gateway-backed launch. Codex already reads its own
config.toml base_url; this brings Claude to parity.
Add a fallback: read Claude Code managed settings and treat the launch as
gateway-backed when env.ANTHROPIC_BASE_URL is a Databricks AI Gateway URL
(validated with is_databricks_ai_gateway_url) and a credential is
delivered via top-level apiKeyHelper or a truthy env.CLAUDE_CODE_USE_GATEWAY.
Managed settings win at the real launch, so this signal can flip the
answer to True even when the omnigent provider is subscription.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): validate the resolve-path base URL as a Databricks AIGW
The resolve-based branch of claude_gateway_inference_backed() returned
True on just ANTHROPIC_BASE_URL + api_key_helper being present, without
checking the URL is actually a Databricks AI Gateway. A bare
api.anthropic.com (or any non-Databricks Anthropic-compatible endpoint)
would qualify — but the external task_v1 router's picks are Databricks
catalog ids that endpoint cannot serve. Require
is_databricks_ai_gateway_url() on the resolved base URL too, matching the
managed-settings fallback and the Codex check.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): resolve cli-config codex base URL from the shared config.toml
native_codex_launch_base_url() returned None for a cli-config launch,
because such a launch pins only a model_provider name — the provider
table (with base_url) lives in the user's shared ~/.codex/config.toml,
which the launch never inlines. So codex_gateway_inference_backed()
reported a genuinely AIGW-routed cli-config provider as not backed,
gating Smart Routing off. This is the Codex analogue of the Claude
managed-settings gap.
Read the shared config.toml in the final branch: extract the pinned
provider name (codex_session_meta_model_provider), locate the user's
CODEX_HOME config via _codex_home_config_source_from_env, and return
model_providers.<name>.base_url with tomllib. openai (Codex's own login)
and omnigent_databricks (the profile branch's generated id) have no
user-config table, so they stay None. Any read/parse failure returns
None — an unreadable config is unknown, not backed. codex_gateway_
inference_backed() is unchanged; it validates the URL as before.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): resolve codex config-default base URL for the empty-override launch
The prior commit covered a cli-config launch that pins a model_provider
name, but the user's Databricks-wide setup hits a different path: when no
omnigent provider resolves and the config default is not dismissed,
resolve_native_codex_launch leaves config_overrides empty on purpose so
Codex uses its own config.toml top-level model_provider default. On such
a machine that default is a Databricks AIGW provider, yet the probe saw
empty overrides and reported not-backed.
Extend native_codex_launch_base_url: when a launch pins no model_provider
override and no profile, resolve the config.toml top-level model_provider
default's base_url (unless the user dismissed the default, which pins
Codex's built-in openai). An explicit model_provider="openai" override
(subscription / dismissed paths) still returns None — only a truly
unpinned launch reads the config default. Factor the shared table lookup
into _config_toml_provider_base_url, used by both the cli-config and
config-default paths.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): count a resolvable launch base URL as codex readiness
_codex_auth_unavailable_reason() detected a provider-routed launch only
via a profile or a non-openai model_provider override. On a Databricks-
wide machine the launch pins neither — omnigent defers to Codex's own
config.toml top-level model_provider default — so readiness fell through
to the auth.json check, found no openai credential, and falsely reported
needs-auth even though bare `codex` works. That gated the Smart Routing
harness row off in New Chat (it needs both claude-native and codex-native
ready).
Broaden the predicate to also count a resolvable launch base URL
(native_codex_launch_base_url(launch) is not None), which now resolves
the config.toml provider default. This only adds a ready case: an
explicit model_provider="openai" pin still returns None from that helper,
so a genuinely logged-out openai user still reports needs-auth. Readiness
now agrees with the launch resolver and the gateway-inference check.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: wrap the codex config.toml fixture under the line limit
Split the three identical model_providers config-toml f-strings across two
adjacent literals so each line stays under 99 chars, clearing the ruff E501
that failed pre-commit.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): show hidden files by default and make the eye icon read as state
The Files panel hid dot-prefixed paths until the user toggled the eye, and
the icon showed the pending action rather than the current state — a slashed
eye while hidden files were visible. Show them by default and flip the icon
so a plain eye means visible, a slashed eye means filtered out.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e): pin hidden files visible by default in the Files rail
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Session search still timed out after #4546. The correlated EXISTS added
there is correct, but Postgres never used the plan it was written for:
the predicate was spelled `lower(search_text) LIKE ?`, which is exactly
the expression the pg_trgm index from d5e9f1a2b3c4 is built on. The
planner therefore preferred that index and scanned every item in the
workspace out of a 2.2 GB index that does not fit in 456 MB of
shared_buffers.
Match with ILIKE on the raw column instead. Same case-insensitive
substring semantics, but it cannot match the index expression, so the
planner uses the (workspace_id, conversation_id) btree the correlated
EXISTS targets. The trigram index is deliberately kept — this only stops
this one query from being drawn onto it, and needs no migration.
_fetch_search_snippets had the same predicate and the same problem; it
would have become the next timeout once the main query got fast.
Measured against the deployed database, planner settings at defaults:
term before after
%claude% 0.03 s 0.02 s
%speed% >30 s 6.42 s
%zzqqxwv% >30 s 10.84 s
snippets >25 s 1.13 s
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): delete sessions optimistically so the row leaves the sidebar at once
Archive removes its row on a single PATCH, but delete kept the row in
place (behind a "Deleting..." placeholder) until the stop_session +
DELETE round trip finished -- seconds of runner, worktree, and managed
sandbox teardown.
Both delete mutations now paint in onMutate the way useMoveToProject
does: the row is spliced out of every cached list, the session is
tombstoned so a concurrent list fetch can't repaint it, and a failure
restores the snapshot and reports via a toast.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the optimistic delete contract in the sidebar
Assert the row unmounts outright with no in-flight placeholder standing
in for it, and add a rollback test: a DELETE stubbed to 500 puts the row
back and raises a toast naming the session. The rollback is only
reachable if the row left before the server answered, so it is the
load-bearing proof that delete is optimistic -- and it covers the
failure path the removed inline error/retry row used to own.
Also refreshes comments that still described the old "Deleting..." row.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Four host-side fixes from a host-log forensics pass:
- An endpoint that accepts the WS upgrade but never sends a frame no
longer spins on the 0.5s recycle cadence forever (observed: ~6s
cycles for 7 hours, silently): past 10 consecutive accepted-but-
silent connections the host logs one ERROR, notifies the terminal
once, and drops to normal backoff until a frame arrives.
- ensure_local_omnigent_server no longer strands a slow-booting child:
while the process is alive the readiness wait extends to a 120s boot
ceiling (a ~39s first boot was observed failing the old 45s cutoff),
and a final failure terminates and reaps the child before raising —
previously it cleared the pidfile and left the server running,
untracked.
- A runner zygote that died mid-life is reaped and respawned on the
next launch instead of latching _zygote_disabled for the daemon's
life; start failures and alive-but-broken channels still disable it.
- Self-allocated process logs that never received a record are swept
at exit, and host shutdown awaits the reaper/watcher cancellations.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
A launcher (e.g. Databricks' isaac) sets CLAUDE_CODE_USE_GATEWAY=1 and
ENABLE_TOOL_SEARCH=true in its process env so the native-claude harness keeps
MCP tool search on (schemas load on demand). But the host daemon env
(`_build_host_daemon_env`) and the runner env (`_build_runner_env`) are both
built from `_RUNNER_ENV_ALLOWLIST`, and neither var was on it — so they were
stripped at daemon spawn and never reached the runner process.
The native-claude provider path (`_provider_config_for_native_claude`,
`_ucode_config_for_profile`, `_bedrock_config_for_native_claude`) reads
CLAUDE_CODE_USE_GATEWAY from os.environ to decide whether to set
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. With it stripped, the runner saw it
absent, re-added the disable flag, and Claude Code turned tool search off —
loading every MCP tool schema eagerly (~88k tokens for ~190 MCP tools at
startup instead of on demand).
Add both non-secret boolean flags to `_RUNNER_ENV_ALLOWLIST`, beside the
existing CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_SKIP_BEDROCK_AUTH flags (same
category). The single allowlist is consulted by both gates, so the vars now
survive daemon spawn and runner spawn and reach the guard.
Tests: assert both vars survive `_build_host_daemon_env` (local + remote) and
`_build_runner_env`. They fail before this change and pass after.
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
* feat(desktop): merge the sidebar header into the macOS title-bar row
On the macOS shell the sidebar started 2.25rem down, leaving a band of empty
canvas above it for the traffic lights to float over — so the window's top-left
held blank space, and the sidebar's own header (wordmark + Search/Settings/
Collapse) sat below it on a second row.
Reclaim that row. The header is already 3rem tall — taller than the 2.25rem
title-bar strip — so it can host the lights itself:
* the sidebar starts at the window's top edge (margin-top: 0), removing the
empty strip;
* the brand mark is dropped, since the lights own the row's left end;
* the action cluster slides left to sit beside the window controls, ordered
Collapse, Search, Settings outward from them.
The buttons align to the LIGHTS, not to the row. The row centres its children
at y=24 while the lights sit at ~y=19, and ~5px off reads as broken once the
two are side by side. macOS paints the lights outside the page — they are not
in the DOM and do not appear in a page screenshot — so there is nothing to
measure against; the rules anchor to the same 2.25rem strip height the drag
region already uses, centring a 1.5rem button in it at y=18.
/settings swaps the header row out for its Back row, which would then sit
underneath the window controls, so that row gets vertical clearance instead.
All of it is scoped to [data-electron-mac]: a browser tab has no window
controls to align to and keeps today's wordmark row untouched. The CSS test
asserts that scoping (and fails if a rule leaks out unscoped), since the whole
change is CSS and the lights are invisible to any DOM-level test.
Verified in the desktop shell: sidebar at y=0, wordmark display:none, cluster
at x=80 ordered Collapse/Search/Settings, buttons at y=18. Toggling
data-electron-mac off restores the browser layout exactly (wordmark visible,
pl-4, space-between, buttons at y=24).
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(desktop): keep the title-bar icons in place when the sidebar collapses
The Search/Settings/toggle cluster lived inside the sidebar, so collapsing the
sidebar took the icons with it: the window's top-left emptied out and the only
way back was ChatHeader's own button, lower down and out of line with the
traffic lights. Peeking was worse — the card floats at inset-2, dragging the
cluster off the lights' centre line (as Polly noted on this PR).
Hoist the cluster out of the sidebar into the macOS title-bar strip, so it holds
one fixed position across open, collapsed, and peeking. Keeping it in the
sidebar was not an option: when collapsed the sidebar is md:w-0 with
overflow-hidden AND inert, so an in-sidebar cluster is clipped and unclickable —
correctness behaviours worth undermining for nothing.
* SidebarHeaderActions is the single source of the markup, rendered by the
sidebar everywhere else and by AppShell on mac. The toggle derives its icon
and label from `expanded` (open || peek), so collapsing swaps Close→Open.
* Dwell-to-peek moves with the button. It was armed on ChatHeader's toggle,
which is now hidden on mac, so the 400ms timer is mirrored in AppShell —
otherwise peek would only work on a button the user can no longer see.
* ChatHeader's open-sidebar button is hidden on mac: the title-bar toggle is
always present and carries the same peek, so it would be a second, offset
copy of one control. Kept everywhere else, where it is the ONLY way back.
* The emptied header row collapses from 3rem to the strip's 2.25rem rather
than leaving the dead band this change set out to reclaim.
The cluster needs z-index 51: the sidebar is a positioned sibling at z-index 50
with an opaque gradient background, so at any lower layer the buttons measure
correctly in the DOM while being invisible on screen. Geometry assertions cannot
catch that — it took a screenshot — so the CSS test now pins the stacking too.
Verified in the shell across all three states: cluster fixed at x=80/y=6 with
button centres at y=18 (the lights' line) while the sidebar goes 320 → 0 → peek;
dwell on the title-bar toggle opens the peek card; a quick pass-over does not;
and exactly one sidebar toggle is hit-testable.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(desktop): float the peek card below the macOS title-bar controls
The peek card floats at inset-2, 8px from the window's top — which on this shell
puts its first row level with the traffic lights and the icon cluster, so the
card slid up underneath the window controls and collided with them.
Drop its top edge to 2.75rem: clear of the 2.25rem title-bar strip, plus the
same 0.5rem breathing room the card's other edges already use. Scoped to
.is-peek, so the docked sidebar is untouched — only the floating card moves.
Measured in the shell: card top y=44 against a controls bottom of y=30, a 14px
clear gap where the two previously overlapped.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(desktop): pin the sidebar open on /settings so the Back row stays reachable
Collapsing the sidebar on /settings stranded the user. The settings nav replaces
the session list INSIDE the sidebar, so its "Back" row is the only way off the
page — and once collapsed that row is clipped by md:w-0/overflow-hidden and
inert, leaving no visible exit. Reproduced in the shell before fixing: on
/settings at width 0, zero reachable "Back" controls.
Entering /settings now pins the sidebar open and hides the title-bar cluster:
* the sidebar is forced open (and any peek dropped — a transient hover card is
not somewhere to read a settings page from);
* the Search/Settings/toggle cluster steps aside rather than offering a
collapse that would break the page;
* toggleLeftSidebar refuses the collapse direction while on /settings, since
the hotkey (⌘⌥[) and command palette reach it without the button. Opening
stays allowed; only collapsing is refused.
The pin is deliberately ONE-WAY: leaving /settings does not restore a prior
collapsed state. Reversing it would collapse the sidebar out from under someone
who had just been using it, and stashing the pre-settings state resurrects a
preference last expressed before a detour the user may not connect to it. The
tradeoff is a visible exit over a preserved preference; the toggle is one click
away on the way out.
Verified in the shell: collapsed at home -> enter Settings -> sidebar expands to
315, cluster hidden, Back reachable (top y=44, clear of the 36px light strip);
⌘⌥[ while there leaves it expanded; returning home keeps it expanded with the
icons back.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(desktop): drop the dead header row inside the macOS peek card
The peek card carried the sidebar's header row, leaving 2.25rem of empty canvas
above "New session". That row earns its space on the DOCKED sidebar, where it
reserves the title-bar strip for the traffic lights and the icon cluster — but
the peek card already floats below all of that (top: 2.75rem), and both the
wordmark and the cluster inside the row are hidden on this shell, so in peek it
is pure padding.
Hide it while peeking so the card's content lines up against its own top
padding. Scoped to .is-peek: the docked sidebar keeps the row, since that is
what holds the window furniture clear of the session list.
Measured in the shell: the gap above "New session" drops from 44px to 8px (the
card's own padding) — 36px reclaimed — while the docked sidebar's row stays 36px.
Also fix a false positive in the CSS scoping test: it asserted that
[data-electron-mac] sits IMMEDIATELY before each class, which a further-qualified
selector like `[data-electron-mac] .conversations-sidebar.is-peek
.sidebar-header-row` fails despite being correctly scoped. It now parses whole
selectors and requires the scope somewhere in each. Verified it still catches a
genuinely unscoped rule.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(desktop): restore the sidebar's open state after leaving /settings
Pinning the sidebar open on /settings silently discarded a collapsed sidebar: a
trip to settings and back left it expanded, undoing a preference the user had
set. Stash the state on entry and restore it on exit, mirroring
sidebarOpenBeforeMaximizeRef around the maximize flow.
This keeps both halves of what the pin was for. The pin is only needed WHILE on
the page — the Back row is the only exit there — so restoring on the way out
cannot reintroduce the trap: by then the title-bar toggle is back and Back is no
longer the only way out. Collapsing is still refused for the duration of the
visit, and the stash is captured inside the state updater so it reads the
pre-pin value rather than a stale closure, and so a re-render while already on
/settings cannot overwrite it with the pinned-open value.
Supersedes the earlier one-way behaviour, which traded the preference for the
visible exit; this gets both.
Also fixes two tests that fired the sidebar hotkey as `{ key: "[" }`. The
handler matches `e.code === "BracketLeft"` (⌥ turns "[" into "“" on macOS), so
the chord never matched and "refuses to collapse while on /settings" was passing
vacuously. With `code` sent, that test now fails when the guard is removed and
passes with it — confirmed by temporarily deleting the guard.
Verified in the shell: collapsed -> settings (pinned open, 315) -> back ->
collapsed (0); open -> settings -> back -> open (315).
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(desktop): dismiss a peeking sidebar once the pointer is elsewhere
The peek card could sit open indefinitely. It closes itself on its own
pointerleave, which is enough when peek is armed from a button INSIDE it — but
the title-bar trigger sits outside the card, so a pointer that dwells there and
then moves away without ever crossing the card leaves it with no pointerenter,
therefore no pointerleave, and nothing to close it. Self-inflicted: hoisting the
toggle out of the sidebar is what moved the trigger outside the card.
Watch the document while peeking instead. Once the pointer is over neither the
card nor the trigger, dismiss on the same 200ms grace the card already uses, so a
wobble between the two doesn't. A click outside dismisses immediately — by then
the user has committed their attention elsewhere and a grace period just reads as
sticky. Radix poppers, menus, dialogs and tooltips count as inside, so opening a
row's context menu can't dismiss the card underneath it.
Verified in the shell: armed from the title-bar button then moving away
dismisses (previously stuck open); moving onto the card and back to the trigger
keeps it; a click outside closes it inside the grace window.
The regression test is load-bearing — confirmed it fails with the pointermove
listener removed and passes with it. The Sidebar mock now renders as
aside.conversations-sidebar and reflects `peek`, so the dismiss logic sees the
same shape in tests as in the app.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
## Related issue
N/A — reported directly after #4559.
## Summary
- On a Databricks workspace-hosted server, the iOS app renders its top controls
under the status bar / Dynamic Island: the sidebar toggle sits level with the
clock and the chat header is flush at y=0. Self-hosted (OSS) servers are fine,
and Android is fine.
- All of the shell's iOS insets derive from `env(safe-area-inset-*)`, which is
non-zero only when the document's meta viewport carries `viewport-fit=cover`.
No document ships it, so the bridge script installs it at `.atDocumentStart`.
- The workspace host then reassigns the whole `content` attribute once its app
mounts (`useMobileViewport`, called for the Omnigent route), writing
`width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no` —
no `viewport-fit`. `env()` collapses to 0, so `--omnigent-safe-top` and
`--omnigent-inset-top` become 0 and every rule padding for the notch pads by
nothing.
- Re-asserts the token with a `MutationObserver` on `document.head` instead of
trusting the one-shot injection: the host rewrites again on its own re-renders,
and it may replace the tag rather than edit it. The observer only writes when
`viewport-fit=cover` is absent, so the shell's own write settles instead of
looping.
- Latent until now — the workspace nav bar used to occupy the top of the screen
and pushed the app below the unsafe area. #4559 promotes the app to a
full-viewport overlay to hide that bar, which is what exposes the missing inset.
- Android is unaffected and untouched: it injects measured insets as
`--omnigent-android-safe-area-*` (`MainActivity.kt`) and never depends on
`env()`. Only iOS trusts the page's viewport metadata.
```
documentStart : … user-scalable=no, viewport-fit=cover ← shell installs it
host mounts : … user-scalable=no ← token dropped
env(safe-area-inset-top) = 0px → header y = 0 (under the island)
observer : … user-scalable=no, viewport-fit=cover ← re-asserted
env(safe-area-inset-top) = 62px → header y = 54 (clear)
```
## Test Plan
- `cd web/ios && xcodebuild test -scheme Omnigent -destination 'platform=iOS
Simulator,name=iPhone 17 Pro' -only-testing:OmnigentTests` → all tests pass.
- Manual, iPhone 17 Pro simulator against a real Databricks workspace, measuring
from inside the page (temporary probe, since removed) at three points — page
load, after the host's app mounts, and after further re-renders:
- before this change, once the host mounted: `viewport-fit` gone,
`env(safe-area-inset-top)` `0px`, `--omnigent-inset-top` `max(0px, 0px)`,
`.chat-header` at `y=0`.
- after: `viewport-fit=cover` present at all three points,
`env(safe-area-inset-top)` `62px`, `--omnigent-inset-top` `max(62px, 0px)`,
`.chat-header` at `y=54`, stable across re-renders.
- to confirm the diagnosis before fixing, re-adding the token by hand at
runtime moved the header from `y=0` to `y=54` on its own.
- `pre-commit run --files web/ios/Omnigent/OmnigentWebView.swift` clean.
## Demo
Workspace-hosted server on the iPhone 17 Pro simulator. Before: the sidebar
toggle renders level with the status bar clock. After: it clears the status bar.
Screenshots attached below.
## Type of change
- [x] Bug fix
- [ ] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Not unit-tested: the fix is JavaScript embedded in a Swift string literal and
injected into a live `WKWebView`, and the behaviour it guards against only happens
when a third-party host page mutates the DOM after mount — there's no harness that
reproduces that. Verified by measuring the computed inset and header position in
the page against a real workspace, before and after, including after subsequent
host re-renders. A follow-up worth doing: push measured safe-area insets from
native as Android does, so iOS stops depending on page viewport metadata at all.
## Changelog
Fixed iOS controls rendering under the status bar on Databricks workspace-hosted
servers
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A — no tracking issue; requested directly.
## Summary
- A workspace-hosted Omnigent on iOS was unusable in two ways: connecting with a
bare workspace URL landed on the Databricks landing page instead of the app,
and once on the app the workspace's top-nav bar was still painted over it —
wasting vertical space and letting the user navigate into another workspace app
with no way back.
- Ports the desktop's chrome hide (`web/electron/src/workspace-chrome.js`) into a
testable `WorkspaceChromeScript`, replacing the previous injection that was
gated on `path.starts(with: "/ml/omnigents")` — a path gate skips auth-redirect
landings and the `/omnigent` mount entirely. Keyed on the pinned origin, never
on the path.
- Mirrors the Android `/omnigent` bounce from #4543 on iOS: domain-matched with no
probe, `?o=<org>` and fragment preserved, one bounce per app-page load, wired
into the three iOS equivalents of Android's callbacks — `decidePolicyFor`
(link/redirect navs), `didCommit` (every committed load, incl. the login
chain's POST hand-back) and KVO on `webView.url` (in-page `pushState`, which
fires no navigation callback at all).
- Root cause both of the above depended on: with `allowsInsecureHTTP` (debug
only), a schemeless host was normalized to `http://`, so the app pinned
`http://` while the server redirects to `https://`. Every pinned-origin
comparison then failed silently — the chrome overlay, native bridge trust
(`isTrustedBridgeMessage`, so the server switcher / Chat-Terminal bar / sidebar
drag were dead), media-capture prompts, and load-success recording. A schemeless
host now defaults to https unless it is loopback, mirroring the desktop's
`LOCAL_HOSTS`, so the mismatch can't be created: release builds already reject
`http://` outright and App Transport Security blocks it at the network layer.
- Derives the pinned origin from the pinned URL instead of caching it in a second
field, so the two can't drift, and re-arms the bare-root bounce budget when a
new server is pinned. Drops `loadSucceeded`'s URL argument: its only consumer
discarded it.
ELI5: the app was told "the server is http://host", the server answered
"actually I'm https://host", and every later "is this page still my server?"
check compared the two strings, said no, and quietly skipped its work.
```
connect "dbc-x.cloud.databricks.com"
│
├─ before: http://dbc-x… → pinned http://dbc-x
│ server 301 → https://… → page https://dbc-x
│ pinned != page ─────────► chrome hide / bridge / recents SKIPPED
│
└─ after: https://dbc-x… → pinned https://dbc-x (non-loopback ⇒ https)
bare root ⇒ /omnigent → bounce once
pinned == page ─────────► overlay covers the workspace bar
```
## Test Plan
- `cd web/ios && xcodebuild test -scheme Omnigent -destination 'platform=iOS
Simulator,name=iPhone 17 Pro' -only-testing:OmnigentTests` → all tests pass.
- New/updated unit tests: `WorkspaceChromeScriptTests` (CSS byte-identical to the
desktop's `WORKSPACE_CHROME_HIDE_CSS`, the install-once guard, CSS embedded as
an escaped literal); `WorkspaceMountURLTests` (bare roots on both workspace
domains, query + fragment preserved, port and host-case, non-root paths left
alone, `databricksapps.com` and a `databricks.com.evil.example` lookalike
rejected, non-http schemes rejected); `ServerURLTests` (schemeless host → https
even under the debug policy, loopback → http, explicit `http://` honoured).
- Manual, iPhone 17 Pro simulator against a real Databricks workspace: a bare
workspace URL lands on `/omnigent` and the workspace nav bar is gone. Confirmed
during development with a temporary in-app probe (since removed) reporting
`styleTag:true, position:"fixed", rect:{y:0,h:874}` — the embed root covers the
viewport from y=0 — and independently by the maintainer on the same simulator.
- `pre-commit run --files <touched files>` clean.
## Demo
Before / after on the iPhone 17 Pro simulator against a workspace-hosted server:
the Databricks top-nav bar (logo, workspace switcher, app switcher, avatar) is
painted above the app before, and the app fills the viewport after. Screenshots
attached below.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
The mount-URL rewriting, scheme defaulting and the injected script are
unit-tested. The navigation wiring is not: `OmnigentWebView.Coordinator` needs a
live `WKWebView` plus a SwiftUI context to construct, so the callbacks and the
overlay were verified on the simulator against a real workspace instead.
## Changelog
Connecting the iOS app to a Databricks workspace now opens Omnigent directly and
hides the workspace navigation bar
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
kiro-cli's interactive TUI runs a separate ~97MB bun runtime plus a ~12MB
tui.js bundle that it extracts and initializes on the first interactive
launch; `--no-interactive` is pure Rust and never touches them. That
asymmetry is why one-shot prompts worked while interactive sessions did
not. While the renderer boots, the pane shows "Initializing · type to
queue a message".
Measured against kiro-cli 2.13.0 on a degraded network, that boot took
35-38s across three runs, past the bridge's 30s readiness gate. The gate
then raised "input prompt was not ready before injection", which
proxy_stream catches and reports as connection_error / "Harness stream
connection error." on a TUI that was healthy and became ready seconds
later.
Extend the readiness wait while kiro's own "Initializing" banner is on
the pane, so a slow boot delays the first turn instead of failing it. A
pane that is neither ready nor booting still fails at the caller's
timeout, so a genuinely dead TUI fails as fast as before. Also quote the
pane's error line on timeout so the surfaced failure names the upstream
cause rather than only the readiness timeout.
Verified live on kiro-cli 2.13.0 (the reported version): before, the wait
failed after 30s; after, it waits out the boot, injects, and kiro answers.
Boot and ready markers are byte-identical on 2.10.0, and no launch argv
changes, so behavior is unchanged for older builds.
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
Codex's own backend (ChatGPT account or API key) names models with a
dotted version, `gpt-5.6-sol`. Databricks serving names the same model
with hyphens only, `databricks-gpt-5-6-sol`. Two places sent the wrong
one, so every codex dispatch on a CLI login failed at launch with a 400.
The curated codex catalog carried the Databricks spelling, so selecting
any offered model was rejected. It now carries codex's own slugs, which
still fold to the same comparable spelling, leaving routed-arm matching
unchanged.
The launch default resolved through the generic OpenAI catalog, whose
newest row is the bare family alias `gpt-5.6` that codex rejects as a
family name. Only the Databricks-gateway branch consults that catalog
now; a codex CLI login defaults to a concrete variant from codex's own
catalog. The Databricks branch keeps its hyphenated ids.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(web): pre-warm and keep terminal surfaces alive across switches
Opening the Terminal tab rebuilt everything from scratch on every visit:
a new xterm + WebGL renderer, a WebSocket dialed through the host tunnel,
a freshly forked tmux attach, and a full repaint — and flipping back to
Chat tore it all down, so the cost repeated on every return.
The terminal surface is now a persistent visibility-toggled overlay:
- It mounts hidden as soon as a terminal is reachable, so the attach
pre-warms in the background and the first open is near-instant.
- Chat/Terminal flips toggle visibility instead of unmounting, keeping
the WS + xterm buffer (and scrollback) alive.
- A small LRU keeps the last few sessions' surfaces warm across session
switches (ChatPage stays mounted across /c/:id changes), with per-entry
readOnly snapshots so permissions never leak between sessions.
- Revealing a surface whose transport died in the background retries
immediately with a fresh backoff budget (same reasoning as the
tab-thaw redial); deliberate server closes keep the dead-end overlay.
visibility (not display:none) keeps hidden overlays at layout size, so
FitAddon geometry stays correct and no resize churn hits tmux; hidden
elements don't paint, hit-test, or take focus. The e2e assertions that
checked "no main-terminal-view exists" now assert "none is visible" —
the hidden pre-warmed mount is not a takeover.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style: apply ruff formatting to the e2e visible-surface assertion
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): spin modal action buttons while their work is in flight
Clicking Stop session, Clone, or any other modal confirm button only faded
it (disabled), with no sign work had started — a slow stop or fork read as
a hang. Button already supported a centered spinner overlay via its loading
prop; wire it up at the modal call sites that were only passing disabled.
Drops the transient "Renaming…" / "Deleting…" label swaps: the spinner
covers the label, so they were invisible, and a static label keeps the
button width stable. Converts the two raw buttons in the PoliciesPage
add-policy dialog to the shared Button so they can carry the spinner.
Dialogs that close immediately and report progress elsewhere (session
delete, which shows a "Deleting…" sidebar row) are left alone.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the clone dialog's in-flight spinner
The E2E UI Required gate asks for browser coverage of the loading states,
not just jsdom. Parks the fork request in a route handler so the in-flight
window stays open for the assertions instead of racing a fast fork, then
releases it so the real navigation still completes.
Asserts the idle state before the click too, so a button that always spun
could not pass.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): use a real function as the fork route handler
Playwright stamps a marker attribute onto the handler it is given, which a
builtin method rejects, so passing list.append raised AttributeError at
page.route() time before the browser was ever driven.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The macOS shell hides the native title bar, and the picker filled that freed
strip with a centered "<thread> — <host>" label. But the chat header occupies
the same strip (absolute top-0, taller at h-14), so on a narrow window the
centered label ran straight into the header's action cluster.
Dock the picker at the bottom of the sidebar instead, out of the contested
space: a sidebar row (server glyph + current host + upward chevron) opening a
menu of recent servers plus "Connect to new server…". The drag strip and the
sidebar's traffic-light top margin are unchanged — those keep the OS window
controls off the sidebar card.
The picker now gates on the picker IPC resolving rather than on
isMacElectronShell(), so Windows and Linux desktop gain a picker they never
had; browsers still render nothing.
Also add GET /.well-known/omnigent.json, an unauthed version manifest for
non-browser clients. The desktop shell ships and updates on its own cadence,
so any installed build can meet any server version, and it had no way to learn
what it was talking to before loading the SPA (/v1/info is read by the SPA
after boot, too late to decide how to open a window). The shell fetches it on
every path that loads a server — startup, connect, and server switch — stores
it per window, and forwards it to the SPA.
Compat is the point of the document, in both directions:
* Clients gate on `manifest_version >= N`, never `=== N`, so a newer server
keeps working with an older shell. Adding a field never bumps the version.
* A 404 (every server older than the route), an unreachable host, HTML from
an SPA catch-all, or malformed JSON all resolve to the same pre-manifest
baseline, which means "use existing behavior" — never an error, and never
a blocked connection. The fetch is not awaited before loadURL.
* `.well-known` joins the API-fallback allowlist so an unmatched path under
it returns a JSON 404 instead of index.html. Without that, a shell probing
an older server would get 200 text/html and could parse the SPA shell as a
manifest — the 404 is what makes "no manifest" detectable at all.
The dev proxy forwards /.well-known too; otherwise Vite answers with
index.html and the capability is invisible in local development.
Verified end-to-end in the desktop shell run from source: server route → shell
fetch → per-window store → IPC → renderer, and the baseline fallback when the
manifest is unreachable.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
## Related issue
Closes #
## Summary
- A workspace-hosted Omnigent is mounted as a Databricks workspace *page*, so
the workspace wraps the SPA in its top-nav shell (the dark bar with the
workspace switcher). In the Android shell that bar was still painted: it
wastes vertical space and, worse, lets a user navigate into another workspace
app with no way back into Omnigent.
- The electron and iOS shells already hide it; port the same fix to Android.
New `WorkspaceChromeScript` holds the CSS plus the install-once JS, and
`OmnigentWebViewClient.onPageFinished` evaluates it on every finished
pinned-origin load.
- Keyed on the pinned origin, never on the URL path: the workspace serves the
SPA on more than one mount (`/ml/omnigents`, `/omnigent`) and an auth
redirect can land on neither, so a path guard leaves the chrome visible. The
rule targets Omnigent's own `.omnigent-app` root rather than the
monolith-owned nav markup, so it can't silently break when Databricks
reshuffles its chrome, and is a no-op on standalone builds.
## Test Plan
- `cd web/android && ./gradlew :app:testDebugUnitTest --tests '*WorkspaceChromeScriptTest' --tests '*OmnigentWebViewClientTest'` — 22 tests, all green.
- New `WorkspaceChromeScriptTest` covers the CSS contract, the install-once
guard, and that the CSS is embedded as an escaped JS string literal.
- `OmnigentWebViewClientTest` now asserts injection order (chrome CSS before
the facade, whose callback declares the page ready), injection without the
facade fallback, that injection is *not* gated on the UI mount path, and that
an off-origin load injects nothing.
## Demo
N/A — logic-only parity port; the CSS is unchanged from the electron and iOS
shells, which already ship this behaviour.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Unit tests cover the script contents and the injection points in the WebView
client. The visual result is the same CSS the electron and iOS shells already
apply.
## Changelog
The Android app no longer shows the Databricks workspace navigation bar around
Omnigent when connecting to a workspace-hosted server.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(harnesses): surface Hermes in the web harness picker
Hermes is a valid, installable harness (present in valid_harnesses and
harness_modules with declared capabilities) but had no harness_labels entry, so
harness_catalog() -- which iterates the labels -- dropped it from
GET /v1/harnesses. The web picker therefore never listed Hermes even though
"omnigent setup" (which hardcodes the row) shows it ready. Add the label,
matching the subprocess-harness convention of codex/cursor/pi. The frontend
already maps the hermes harness to HermesIcon, so no frontend change is needed.
Closes#1939
Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
* fix(harnesses): thread the spawn env for the hermes picker row
Adding hermes to `harness_labels` makes it selectable in the web picker, but
hermes had no spawn-env builder and no `model_env_keys` entry, so
`_build_spawn_env_from_spec` returned None for it and the subprocess started
with no per-session config at all. The wrap then applied its own defaults, and
three picker choices became silent no-ops:
- a selected sandbox fell back to the wrap's `caller_process` + `sandbox=none`,
so a session the UI showed as sandboxed ran unconfined;
- the session workspace fell back to the runner-wide `OMNIGENT_RUNNER_WORKSPACE`
instead of the folder the user picked;
- `/model` was rejected up front, since `harness_supports_model_override`
derives from `model_env_keys`.
Add `_build_hermes_spawn_env`, modelled on the kimi builder: hermes owns its
file-based auth (`hermes setup` / `hermes model`, credentials under its
`HERMES_HOME`), so there is no gateway/provider surface to configure and the
builder threads only model, cwd, skills filter, and the serialized `os_env`.
Unlike kimi it does emit `HARNESS_HERMES_SKILLS_FILTER`, which the executor
turns into its `-s` / `--ignore-rules` argv. `HARNESS_HERMES_BUNDLE_DIR` stays
unset: it is reserved in the wrap with no `hermes chat` flag to carry it, so
emitting it would set a var the executor cannot pass on.
Register the builder on the `hermes` arm of the runner dispatch chain, matching
the eleven sibling builtins, and add the model env key so `/model` reaches the
subprocess.
Tests: hermes joins the shared parametrized cwd and `OMNIGENT_*_PATH` suites,
gains four builder tests beside its kimi peer, a dispatch-chain guard (having a
builder does not prove the chain reaches it), and a guard that the picker row's
model plumbing exists. Each fails on the unfixed tree for its own reason.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
---------
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
Co-authored-by: Dhruv Gupta <dhruv0811@gmail.com>
Migration d5e9f1a2b3c4 (revision unchanged) now builds its pg_trgm GIN
indexes inside Alembic's autocommit_block with CREATE INDEX CONCURRENTLY,
so a large conversation_items table never blocks writers during the build.
A failed concurrent build leaves an INVALID index that IF NOT EXISTS would
keep, so any such leftover is dropped before (re)creating.
_run_migrations hands Alembic a non-transacted connection so Alembic owns
transaction demarcation — autocommit_block cannot suspend an externally
begun transaction.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Session search matched chat content with an uncorrelated
`conversations.id IN (SELECT DISTINCT conversation_id FROM
conversation_items WHERE lower(search_text) LIKE ...)`. Because the
subquery is uncorrelated, Postgres materializes the match set for the
ENTIRE workspace before the outer query discards every row the caller
cannot see — so the cost scales with total workspace size rather than
with what the user can actually access.
On the deployed instance (3.16M conversation_items / 12 GB) that ran past
the 15s search statement_timeout on every query, including terms with no
matches at all, so search returned nothing. The pg_trgm index added in
d5e9f1a2b3c4 does not help here: the index is 2.2 GB against 456 MB of
shared_buffers, so each scan reads it from storage.
Switch the predicate to a correlated EXISTS. Correlating on
conversation_id keeps each probe on the existing
(workspace_id, conversation_id) index and stops at the first matching
item per conversation. Measured against the deployed database: the same
search goes from a 15s timeout to 2.36s (cold cache).
Results are unchanged — the two forms match exactly the same rows.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
## Related issue
N/A — build configuration chore.
## Summary
- `versionCode` was already overridable with `-PversionCode=…`, but `versionName`
was a hardcoded literal, so every release build required editing
`app/build.gradle.kts` and committing the bump. Both are now overridable at
build time, with the checked-in values as defaults.
- Added a `buildProperty()` helper that reads a Gradle property and treats blank
as absent. This also fixes an existing rough edge: `-PversionCode=` with an
empty value (what the CI workflow passes on PR-triggered runs, where the
dispatch inputs are unset) was taken literally instead of falling back.
- Threaded a new optional `version-name` input through the `Android Bundle`
workflow and quoted both `-P` args so an empty value stays a single token.
- Documented the override in `web/android/README.md` under a new "Versioning"
heading, and removed the two now-stale "bump `versionCode` in
`app/build.gradle.kts` before each upload" instructions.
Note: the repo's `Bump Version` workflow still only bumps the Python packages, so
the checked-in `versionName` default can drift from the release version. Folding
Android into `scripts/update_versions.py` is left for a follow-up.
## Test Plan
Verified the property plumbing at configuration time with a throwaway Gradle init
script that reflects into `android.defaultConfig` and prints the resolved values:
```sh
cd web/android
./gradlew -I /tmp/print-version.gradle.kts help -q # name=0.1.3 code=9
./gradlew -I /tmp/print-version.gradle.kts help -q \
-PversionCode=42 -PversionName=9.9.9-rc1 # name=9.9.9-rc1 code=42
./gradlew -I /tmp/print-version.gradle.kts help -q "-PversionCode=" "-PversionName=" # name=0.1.3 code=9
```
All three matched expectations: defaults apply with no flags, overrides take
effect, and blank values fall back to the defaults (the CI PR-event path).
`pre-commit run --files …` passes; ktlint rewrapped the helper's signature.
## Demo
N/A — no user-visible surface; build configuration only.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
The change is Gradle build configuration, which the test suites do not cover.
Verified manually via the three `./gradlew` invocations above, asserting the
resolved `versionCode`/`versionName` for the default, overridden, and
blank-value cases. The existing `Android Bundle` workflow also runs
`bundleRelease` on PRs touching `web/android/**`, so this PR exercises the
blank-input path in CI.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* feat(files): let the file panel navigate anywhere the session can reach
The web UI's file panel was pinned to the session's starting directory.
That confinement was a UI limitation, not a security boundary: every
native coding agent ships `sandbox: {type: none}`, so the session's own
shell already reads and writes anything the runner can. The panel simply
refused to display it — `_validate_path` rejected absolute paths outright,
and there was no way to name a location outside the workspace at all.
Naming a location: a leading `/` means absolute, on both `filesystem` and
`search`. Relative paths keep the historical contract, traversal guard
untouched. Only the first slash is percent-encoded on the wire, since a
literal `//` is what proxies collapse.
Authorization: `reachable_roots()` enumerates cwd plus the declared
sandbox grants, and `_assert_within_reach` now consumes that same list, so
what is enforced and what is advertised cannot drift. Absolute paths are
accepted only when the server vouches for the caller, which it does after
checking LEVEL_EDIT — the level that already grants shell. A confined
agent gets no widening, and a read grant still never confers write.
Search follows the tree, with a scan budget modeled on
`scan_cwd_mask_entries`: a query matching nothing never fills the result
cap, so a walk from a large directory needs its own deterministic bound.
Dependency and cache dirs are walked last so the budget covers real
content first.
UX: the working-folder path becomes clickable and opens the same
directory browser the new-session flow uses, which brings its typed path,
Up / Home and show-hidden along. A workspace-root button returns you in
one click. Because navigating a viewer cannot move the agent's working
directory, the composer tells the agent where the user is looking.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): authorize the host-fallback root lazily; cover browsing in e2e_ui
Absolute browsing was refused on a runner-only session. The read routes
resolved the host-fallback workspace eagerly, and that resolution needs a
recorded `conversation.workspace` — which a session with no bound host does
not have. A live runner authorizes the path itself against its own resolved
policy, so the resolution only matters when the host fallback is actually
taken; deferring it until then fixes those sessions.
Adds the e2e_ui coverage that caught it: bind a session to a stubbed host,
open the working-folder path, pick a directory outside the workspace and
assert both the tree and search re-root there. Only the host binding is
faked — the reach, the authorization and the listing are the real server,
runner and filesystem.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): address review — scoped-search stat, read-grant writes, tight budget
Three defects Polly's review found, each with a regression test that fails
without its fix:
- Scoped search statted the result path, which is relative to the search
base, while the helper's cwd is the workspace root. An absolute or
subdirectory search therefore reported null metadata, or a same-named
workspace file's size and mtime. Stat the full path instead.
- `_within_grants` ignored the access being requested, so in an unconfined
environment a write landing inside a READ grant was routed through the
guarded helper, which denies it — refusing a write the environment's own
shell can already make. The routing decision now considers `need_write`.
- The search scan budget was checked once per directory, so a single very
large directory could overshoot it before `truncated` tripped. Counted
per entry now, in both the runner script and the host-side reader.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): check containment on every browse return; annotate CodeQL alerts
`resolve_browse_target` had one branch that returned the resolved path with
no containment check at all — the unconfined case. State that reach as what
it actually is, a grant rooted at the filesystem root, so every return goes
through the same check. Behaviour is unchanged; the shape is now auditable
without reading the branch order.
The three CodeQL `py/path-injection` alerts are annotated rather than
designed around. The rule does not recognize this codebase's containment
idiom: it already fires, and is already open on main, for this module's
workspace-confined `_resolve` — which normalizes, rejects absolute paths and
`..`, resolves, and then re-checks the resolved path with `relative_to` and
raises. Each annotation records why the flow is bounded at that site.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore(api): regenerate openapi.json for the scoped-search route
The new `/search/{path}` route left `openapi.json` out of sync with
`scripts/dump_openapi.py`, which `test_openapi_drift` guards. Regenerated;
the diff is that one added path and nothing else.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): put the CodeQL suppression markers where CodeQL reads them
A suppression comment is only honoured on the flagged line or the line
immediately above it. The markers were buried mid-paragraph three or four
lines up, so they would not have applied. Justification prose first, bare
marker directly above the expression.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): allowlist the session id before it becomes a path component
`session_id` arrives from the URL and is used as a directory name under the
runner workspace, so it was already sanitized — but with a denylist that
enumerated `/` and `..` and therefore missed a backslash, which is a real
separator on a Windows host, along with NUL and control characters.
Switch to an allowlist. Note the obvious allowlist is not sufficient on its
own: `[^A-Za-z0-9._-]` permits `.`, so it leaves `..` untouched and would
REINTRODUCE the traversal the old denylist did stop. Dots are handled
explicitly, so a component that is empty or all dots can never be emitted.
Tests pin both the component and the property callers depend on (the joined
workspace path stays under the runner root). They fail against the old
denylist (7 cases) and against the plain allowlist (3 cases).
This is the sanitizer CodeQL's `py/path-injection` alerts trace back
through; it could not see the denylist inside the callee.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(paths): make the containment checks the ones CodeQL can verify
Guessing at this scanner twice was wrong, so I ran it: downloaded the CodeQL
bundle, built a database from this repo, and read the query's own definitions.
`py/path-injection` is a two-state machine. A tainted path starts
`NotNormalized`; only `os.path.normpath` / `abspath` / `realpath` move it to
`NormalizedUnchecked`; and the ONLY thing that then clears it is
`str.startswith` used as a guard (`StartswithCall` is the single
`SafeAccessCheck::Range` in the whole Python model). The query file states
outright that checks are "ineffective in the NotNormalized state".
Two consequences the code was on the wrong side of:
- `Path.resolve()` is a *sink* (`PathlibFileAccess`) but NOT a normalization —
pathlib is explicitly unmodeled there ("TODO: Handle pathlib"). So resolving
through pathlib touches the path while it is still unchecked.
- `relative_to` in a try/except is not a recognized check, so the guard that
was there could never clear anything. Neither could the suppression comments
or the sanitizer allowlist — and Copilot Autofix's suggested regex would not
have either, besides reintroducing the `..` traversal it fails to strip.
So containment now goes through one shared primitive, `contained_realpath`:
realpath first, then a prefix test, then hand back the result. Both sides of
that test carry a trailing separator, which is what stops a boundary at
`/data` from admitting `/database` while still admitting `/data` itself — the
separator is stripped again before returning so callers get an ordinary path.
`ReachableRoot.prefix` is the one definition of a grant's boundary, shared by
`contains()` and by the callers that inline the comparison.
Verified against the real query rather than asserted: origin/main reports 57
path-injection alerts, this branch reported 61 before (+4), and 53 after (-4).
The four new ones are gone, and so are four that predate the PR — the session
workspace join and the workspace-relative resolve now assert containment at
runtime instead of relying on the caller having sanitized the input.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(paths): pin the symlink-loop case the containment rewrite changed
A differential over 20k generated paths found exactly one behavioural
difference between the old pathlib containment and the new one: a symlink
cycle inside the boundary. `Path.resolve()` raised ELOOP so the check
refused it; `realpath` returns it unresolved so containment admits it.
Nothing escapes -- the cycle stays under the boundary and every syscall
through it fails with ELOOP, so the refusal moves from the check to the
read. Pinned so it is not later mistaken for a hole and 'fixed'.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): require session ownership to browse outside the workspace
Gating absolute paths at LEVEL_EDIT made this route a weaker parallel path
to `/v1/hosts/{id}/filesystem` — the endpoint behind the workspace picker,
which is owner-scoped ("Authorizes (owner check)… don't leak existence to
non-owners"). An EDIT collaborator on a shared session could not browse the
host through that endpoint, but could read the very same files through this
one. That is a bypass, not just an inconsistency.
Absolute paths now require LEVEL_OWNER, on reads, search, and every mutation.
Workspace-relative paths keep LEVEL_EDIT: the workspace is the session's
shared context, so a collaborator who can edit the session can edit it. Past
the workspace is the owner's own machine.
This is not yet a hard boundary — the shell proxy is still LEVEL_EDIT and
unconfined, so an edit collaborator can read the same files by running a
command. That gap predates this branch and is pinned by the strict-xfail
matrix in test_filesystem_path_isolation_e2e.py. What changes here is that
the file panel no longer hands it to them casually, and this route is no
longer weaker than the host endpoint it parallels.
Tests live with the shell gate they mirror rather than in a new file; its
docstring now covers both. Verified they bite: reverting the gate fails
exactly the two edit-collaborator denials, while the read-only case passes
either way (READ is below EDIT regardless).
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(files): drop browse_outside_workspace; ownership is the whole rule
The flag was a second representation of a decision the server already makes.
At every call site it was set exactly when the path started with "/", so it
carried no information the runner could not read off the path itself — a
boolean meaning "trust me, I checked", threaded through seven runner routes.
With absolute paths gated on session ownership, the rule states itself: the
owner may browse outside the workspace, nobody else may. One place decides
it (`_browse_level`), and the split between the two processes is now clean:
server — decides WHO may ask. Absolute path => LEVEL_OWNER, for reads,
search and every mutation. Relative keeps the usual bar.
runner — decides WHAT the environment may reach. Absolute paths are
admitted only by a declared grant or an unconfined policy. It
cannot see the caller, so it no longer pretends to.
The runner keeps a real check of its own: a CONFINED environment still
refuses an out-of-grant absolute path regardless of who is asking. What it
loses is the redundant vouch, so `test_absolute_path_rejected` no longer
holds for the unconfined fixture it used. Rather than delete the coverage,
it is split in two — a confined environment refuses (the runner's own
check), an unconfined one serves (deferring to the server) — with both
sides pointing at where the other half of the guarantee lives.
Coverage for the property itself is the point, so the permission gate suite
now runs the matrix: owner and admin allowed, edit and read-only denied,
across read / search / delete, plus unauthenticated, plus controls proving
the bar applies to absolute paths ONLY and shared sessions still work.
Reverting the gate fails eight of them.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): gate any absolute path shape at owner, not just POSIX
The owner gate tested `client_path.startswith("/")`, which is the wire
form this API defines — but the gate decides IDENTITY, and a
`C:\\Users\\...` or UNC path is absolute too. Those were treated as
workspace-relative and admitted at the collaborator level, stopped only by
the runner refusing them further down. An identity decision should not rely
on a later layer catching it.
`ntpath.isabs` is true for a POSIX leading slash as well as Windows drive
and UNC roots, so it fails closed on every absolute shape while leaving
workspace-relative paths untouched.
The wire-format decision stays `startswith("/")`: encoding the runner URL
is a URL question, and URLs use `/` everywhere. The two predicates can
disagree only for a Windows-shaped path, where the result is a stricter gate
plus a runner-side refusal — closed on both counts. Separately: the
containment primitive keeps `os.sep`, which is right there because it
compares real filesystem paths from `os.path.realpath`, not URL segments.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e): prove only the owner can browse outside a shared workspace
The route-level matrix stubs the permission store, so nothing proved the
wiring between a genuinely shared session and the gate. This drives it end
to end: one live server, a real session, a real PUT /permissions grant at
EDIT (the strongest level short of ownership), and two browser contexts
carrying different identities.
The owner opens the files panel, navigates outside the workspace and sees a
file that exists ONLY there. Bob, granted the same session, reaches the same
directory and the panel names the reason instead -- 'needs owner permission
on session ...' -- and the same request over his own authenticated context
is 403.
The refusal is asserted as a POSITIVE signal on purpose. The obvious
version, 'owner-only.txt is not present', is satisfied the instant the page
loads and passes with the gate removed entirely; I confirmed that by
reverting the gate and watching it pass before the API check caught it.
Reverting the gate now fails at the UI assertion, where an e2e test should
fail. Bob's navigation is also asserted to have happened, so the absence is
about the fetch being refused rather than the click silently not landing.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): keep the browse affordance when the agent is asleep
Navigating outside the workspace silently stopped working once a session's
runner went to sleep. The server synthesizes the environment resource itself
in that state, and the synthesis emitted only `metadata.root` -- no
`reachable`. The panel gates its navigation control on that field, so it read
"nowhere else to go" and fell back to the plain, unclickable label.
Nothing was wrong below it: with the runner offline I confirmed the
host-served path already lists an absolute directory (200) and runs an
absolute-scoped search (200), because `_authorize_absolute_browse` authorizes
the target server-side before the host is handed a root. Only the
advertisement was missing, and the advertisement is what the UI gates on.
The payload shape now has one definition, `sandbox.reach_payload`, used by
both producers -- the runner while the agent is awake, the server while it
sleeps -- so a browser cannot be told one thing by one and something else by
the other. That is the same enforce-and-advertise-from-one-source rule
`reachable_roots` already follows.
The regression test asserts the whole payload rather than the field's
presence, since a synthesis that advertised a *different* reach from the
runner's would be its own bug. It fails with `KeyError: 'reachable'` against
the previous synthesis.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): don't offer the browse control to a non-owner
A shared collaborator could click the working-folder path, and then nothing
loaded. The panel gated the control on `metadata.reachable`, which describes
what the ENVIRONMENT can reach and is byte-identical for every viewer of a
session -- so it cannot answer "may THIS person go there". Confirmed against a
live shared session: owner and collaborator receive the same `reachable`
payload while their permission levels are 4 and 2.
Two things then went wrong for the collaborator: the absolute browse is
refused 403 by the owner gate, and the picker itself reads the owner-scoped
`/v1/hosts/{id}/filesystem` endpoint, which also 403s -- so the control opened
onto an error. Offering an action that is guaranteed to fail is worse than not
offering it.
The panel now also consults the viewer, via the existing `isOwnerLevel`
helper that the workspace rail already uses to decide `readOnly`. It is read
off the session snapshot the panel already fetches for `hostId`, so no prop
threading and no extra request. `isOwnerLevel(null)` stays permissive, which
is what keeps browsing available to the only user of a single-user server.
This is presentation, not the boundary: the server's LEVEL_OWNER gate is
unchanged and remains what actually refuses the request. If the two ever
disagree the worst case is a control that 403s -- exactly today's behaviour --
so the e2e asserts BOTH halves: the collaborator is not offered the control,
and the same request over their own authenticated context is still 403. That
second assertion is what fails if the server gate is ever removed.
Reverting the client gate fails the e2e, and the unit tests cover owner,
collaborator, and the unknown-level single-user case.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style: apply ruff formatting to the merged test file
The merge landed my offline-synthesis block next to main's gzip-route block;
ruff format wants a blank-line adjustment at the seam. The Databricks hook
skips pre-commit during a merge commit, so this was caught by running the
hooks explicitly afterwards rather than by the commit itself.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(files): copy-path buttons; stop injecting the browsed dir into the turn
Removes the browse-location marker the composer prepended to every message
while the panel was pointed away from the workspace. Navigating a viewer is
not something the user asked the agent to act on, and writing it into the
turn made an ambient UI detail part of the conversation the agent reasons
over — on EVERY message, not just file-related ones. Deleted outright rather
than made conditional: `browsingMarkerFor`, the composer preamble, the
`BROWSING_RE` bubble stripper, and the `browseLocation` store field. Nothing
persisted carries the marker (it only ever existed on this branch), so the
stripper had nothing left to strip. The panel keeps its own local browse
state — that is the navigation feature, untouched.
Adds a copy-path button in three places, all one component:
- every file row in Changed and All (hover-reveal, beside the download
button, mirroring FileDownloadButton's placement and feedback pattern)
- the working-folder header, beside the hidden-files eye (always visible),
copying the ABSOLUTE path of wherever the panel is currently pointed
Feedback is transient and in place — a check for two seconds, or a red icon
with "Copy failed" for three. No toast: with a hundred-plus of these on
screen, the confirmation belongs on the row the user clicked.
Two details worth knowing:
The accessible name carries the BASENAME while the clipboard gets the FULL
path. My first cut put the whole path in `aria-label`, which broke four
existing tests: a name like "Copy path: src/app.ts" collides with the
`/src\//i` queries used to find folder-toggle buttons. It is also noise for
a screen reader on every row. FileDownloadButton already uses the basename;
matching it fixes both. A test pins the split, since inverting it (copying
the basename) would be a silent, plausible-looking bug.
I also wrote a test asserting the click does not open the file, then found
it passed with `stopPropagation` removed — the button is a SIBLING of the
row's clickable element, not a child, so nothing propagates. Deleted the
vacuous test and corrected the comment to say the guard is defensive
(FolderTree's directory rows ARE buttons, so a future placement inside one
would need it).
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): align the file rows' trailing controls; copy paths from folders too
Two fixes to the file panel's row layout.
**Alignment.** The trailing controls sat at a different x on every row —
measured on a live tree: 11 distinct positions spanning ~16px. The cause is
the metadata column being content-sized: `formatBytes` ranges from "985 B"
to "463 KB", and in the changed list a diffstat ranges from "+7 −1" to
"+1204 −318". Everything to the LEFT of that variable text — the copy
button, the download button, the git status marker — inherits its jitter.
Pre-existing, but a second icon in the cluster made it obvious.
The metadata column is now a fixed width (`ROW_META_SLOT_CLASS`, exported
from fileStatusUtils so the two row components cannot drift apart) and is
rendered ALWAYS, even when empty — directories carry no size, and omitting
the slot for them kept folders off the same grid as files. Measured after:
one x for every row in the tree, folders and files alike.
**Folders had no copy button.** Not an oversight in placement: the whole
directory row WAS a `<button>` (the expand toggle), so a copy control could
not be nested inside it — a button inside a button is invalid HTML and React
will not render it usefully. The row is now a wrapper div with the toggle as
an inner `flex-1` button and the copy control as its sibling, mirroring how
file rows were already built. The toggle still spans everything up to the
copy button, so the clickable area is effectively unchanged.
That restructure moved the row indent from the button to the wrapper, which
the existing VS-Code-alignment test caught. Updated it to compare row div to
row div — like-for-like, where it previously compared a folder BUTTON against
a file DIV, an asymmetry that only existed because folders were buttons.
Both new tests were verified to fail without their fix: dropping the folder
copy button fails two, and making the slot content-sized again fails the
alignment one.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): pair the copy button with the download button
The copy button sat before the metadata column and the download button
after it, so the two controls were separated by the whole ~56px slot
instead of reading as one action pair.
Both now live inside that column: metadata at rest, [copy][download]
adjacent on hover. Measured on a live tree — 2px apart, and still one x
for every row.
Rows without a download (a folder, a deleted file) render an empty spacer
in its place rather than letting the copy button slide right into the
freed space; `ROW_ACTION_SIZE_CLASS` documents that footprint next to the
slot width it pairs with. The changed list gets the same treatment so both
tabs read identically.
The alignment test moved with the markup: it previously asserted the copy
button's sibling WAS the slot, which stopped being true once copy moved
inside. It now pins what actually matters — the copy button sits in the
fixed column AND is immediately followed by the download button or its
reserved footprint. Verified it fails when anything is inserted between
the two.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): put the copy button to the right of the download button
Swaps the pair's order in all three row types. Measured live: download at
x=1446, copy at 1466, 2px apart, one x for every row.
The alignment test asserted the copy button's NEXT sibling was its pair, so
it flips to the previous sibling — copy is now the rightmost control.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): line the folder dirty-dot up with the file status letter
The two git-status markers ended a tree row's name button but were each
sized to their own content: the dot centred in a fixed 22px box, the A/M/D
letter a variable-width badge centred on itself. Measured live, that put
them 4px apart -- close enough to read as a wobble down the tree rather
than a deliberate column.
Both now centre in the same slot (ROW_STATUS_SLOT_CLASS, exported alongside
the other row-column widths so they can't drift apart). Measured after: dot
and letter both at x=1411.
The existing dot test asserted only the dot's own width, and its comment
claimed the dot aligned with the download column -- which stopped being
true when the rows were restructured. It now checks the shared slot from
both sides, and fails if the letter is unwrapped again.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style: collapse the status-slot cn() call to one line
Prettier keeps the call on a single line -- it fits inside the 100-column
limit. Caught by CI's `prettier --check .`, which failed both the
pre-commit job and the web test job.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): drop the onFlatViewChange prop the merge removed
main took scope out of the panel (it is a rail tab now), so FilesPanelProps
no longer declares onFlatViewChange. One render in the test file still
passed it -- the last reference anywhere in the tree -- which failed the
typecheck. The file's shared renderPanel helper already omits it.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(files): double-click a folder to make it the working folder
Finder's contract: a single click still expands the row in place, a double
click re-roots the panel onto that folder. The header follows, and the tree
redraws at the new root.
Navigating INSIDE the workspace now goes out as a workspace-RELATIVE
location. That is not cosmetic: the server authorizes an absolute location
at owner level -- it can name any path on the host -- so sending a
subfolder's absolute path would 403 every collaborator opening a folder
already listed in front of them. Only genuinely-outside paths stay
absolute, where the owner gate belongs.
Choosing the wire form on authorization grounds means the two forms must
mean the same thing, and they did not: a relative target is echoed back as
a prefix on every entry ("reports" -> "reports/summary.md") while an
absolute one is not. Un-stripped, the browsed folder rendered as an extra
level inside its own tree. Both forms now normalize to paths relative to
the browsed location, which also fixes lazily-expanded children losing
their parent prefix under an absolute location -- expanding one level
deeper had been requesting the wrong path.
Two follow-on corrections the navigation exposed:
- The expanded-paths cache is keyed by browsed location as well as
conversation. Node paths are relative to the root, so a set captured at
one root describes different directories at another; carrying it across
a re-root collapsed the new tree and could expand an unrelated
same-named folder.
- Files opened from the tree get the location re-attached. Tree paths are
relative to where the tree is rooted while the viewer resolves against
the workspace root, so opening a file after navigating into a folder
looked in the wrong place and hung on "Loading...".
Verified live against a running server, confined and unconfined: two
levels deep, lazy expansion at the new root, files opening, and the picker
flow to an outside directory all unchanged.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Zygote-forked runners crash-looped (5x per session start) when the event
loop's signal wakeup fd came up in blocking mode: add_signal_handler's
RuntimeError ('the fd 6 must be in non-blocking mode') escaped main and
killed each fork. Graceful-shutdown handlers are a nicety — the runner
still serves sessions and still exits via the parent-death backstop — so
warn once and continue without them instead of dying.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(compaction): drop base64 payloads from persisted compaction snapshots
Every native forwarder builds `compacted_messages` by copying its vendor
transcript verbatim, so one screenshot turns a single compaction item into
megabytes of base64 that is stored forever and re-read on every session load.
A reported deployment saw ~15 MB per row, 69 MB in a week from five rows.
Stripping in `CompactionData` rather than in each forwarder covers every
producer through one seam. Only newly written rows shrink. Validation runs on
the way out of the store and never back into it, so a row already on disk
keeps its size and the 69 MB already written is not reclaimed; no backfill
here.
`_clear_binary_content` could not be reused: it matches a flat `data` field
or a `data:` URI, and an Anthropic-shaped block carries bare base64 under
`source.data` with neither, so it leaves exactly the payloads this fixes
untouched. `redact_binary_payloads` handles both forms at any depth, since a
tool-returned screenshot arrives inside `tool_result.content`. It rebuilds
rather than mutating, because pydantic aliases the nested dicts through to
the caller. `file_id` and `media_type` survive, so content stays
identifiable and re-fetchable.
A binary block's own payload is redacted before the walk recurses into it.
The other order made every read of an existing 15 MB row run the data-URI
regex over the whole payload only to overwrite it on the next line: 0.00 ms
to 115.08 ms per read, now 0.01 ms.
Reads pay the strip too, so the block type is checked with an isinstance
guard before the frozenset test. A dict or list `type` is unhashable, and
the resulting TypeError is not one pydantic converts, so it would escape
`POST /sessions/{id}/events`, whose compaction branch has no except clause,
as a 500 on input that validated fine before.
Measured: 10 screenshots, 14.67 MB -> 1.72 KB.
Note for reviewers: resume cost is close to zero on claude-native, whose
resume path already discards these blocks (it reads `input_image` shape and
the snapshot is written in Anthropic shape, a separate latent bug).
codex-native is the one real loss: pre-compaction inline images become a
marker on resume, with text and structure intact. Live in-process history is
untouched; only the durable row is stripped.
Fixes#4310
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
* test(server): cover the compaction strip end to end
The strip is well covered at `parse_item_data`, but every existing test
calls that parser directly. This walks the path the bug report describes
instead — the event a native forwarder POSTs, through the conversation
store, back out of `GET /items` — so a regression in the route or the
store surfaces too, not just one in the validator.
Confirmed failing with the validator reverted.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
## Related issue
N/A — chore, no associated issue.
## Summary
- Grants @yaoharry, @marktai and @arthivjkumar maintainer status by appending
them to `.github/MAINTAINER`, the single authoritative list.
- No other changes needed: every consumer (merge gate, security-scan skip,
waiver checks, review SLA sweep, triage self-assign) reads this file at
runtime.
- Appended rather than alphabetized, matching the existing convention for
recent entries.
## Test Plan
- `node --test .github/workflows/areas.test.js` — passes (validates every
`areas.json` owner is present in `.github/MAINTAINER`).
- `pre-commit run --files .github/MAINTAINER` — clean.
## Demo
N/A — non-visual change.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
## Coverage notes
`areas.test.js` already asserts the maintainer list stays consistent with
`areas.json`; ran it locally plus pre-commit on the changed file. The list is
plain text with no logic of its own, so no new tests.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
A host whose loopback server died reconnected forever at the 10s
backoff cap — zombie 'omnigent host' processes looped for days against
dead local ports. Connection-refused on loopback means nothing listens
and no network path can recover, so after 30 consecutive refusals
(~5 minutes at the cap) the host now logs one clear ERROR and exits
through the same fail-loud path as permanent auth failures. Dual-stack
refusals (asyncio's combined 'Multiple exceptions' OSError or exception
groups) count only when every sub-error is refused; any successful
connect or non-refused error resets the streak. Remote server URLs are
unaffected and retry indefinitely so network outages recover.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(runner): classify harness launch failures into clear error cards
Harness terminal-exit failures surfaced as a terse code (e.g.
`required_terminal_exited`) over a raw, sometimes mid-word-truncated PTY
tail — hard to act on. Add one shared classification layer on the common
terminal-exit path so every harness benefits:
- Capture the inner process exit code from tmux `#{pane_dead_status}` and
thread it through `TerminalExitEvent`.
- Fix output truncation to drop whole leading lines instead of slicing
mid-word.
- New `omnigent/runner/launch_failure.py`: declarative matchers →
`FailureDiagnosis(title, cause, remediation)` (root+skip-permissions,
not-authenticated, missing-binary) plus a code→sentence table.
- Carry optional `title`/`cause`/`remediation` on `ErrorDetail`, through the
`session.status: failed` SSE event and durable labels, so a reload renders
the same card. The composed `message` still works for older clients.
- Frontend: `ErrorBanner` renders a friendly card (headline, cause,
remediation, folded details) with a code→sentence fallback.
Co-authored-by: Isaac
* fix(runner): refuse zygote harness forks after an in-place upgrade
A zygote-forked harness child shares the zygote's pre-imported module
graph but imports the harness module itself lazily from disk. When an
in-place upgrade (uv tool install) rewrites site-packages under a
still-running runner, the child mixes new on-disk harness code with the
old in-memory graph and crashes on any new cross-module import, e.g.:
runner: cannot import harness module 'omnigent.inner.claude_native_harness':
cannot import name 'describe_exception' from 'omnigent.inner.executor'
Capture the on-disk build stamp when the zygote imports its graph and
refuse fork_harness once the stamp no longer matches. The runner's
existing fallback then direct-execs a fresh interpreter, which runs the
new code coherently.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
## Related issue
N/A — no tracking issue; requested directly.
## Summary
- A Databricks workspace serves its own landing page at the root and mounts the
Omnigent SPA at `/omnigent`, so an Android user who connects to (or navigates
back to) `https://<workspace>` sees Databricks, not the app. The shell now
rewrites a **bare** workspace root to `<origin>/omnigent`, preserving `?o=<org>`
and any fragment; a URL that already carries a path is a deliberate deep link
and is left alone.
- Applied where the pinned server URL is read (`ServerStore.currentServerUrl`,
expanded on read so the stored/offered entry stays what the user typed) and in
all three `WebViewClient` callbacks that can observe the WebView reaching the
root — no single one sees every case:
`shouldOverrideUrlLoading` (link/redirect navigations; skipped for shell-issued
and POST-driven loads), `onPageStarted` (every committed main-frame load,
including the SSO chain's POST hand-back), and `doUpdateVisitedHistory` (in-page
routing via `pushState`/`replaceState`/history, which loads nothing at all).
- Host matching is by domain (`*.databricks.com`, `*.azuredatabricks.net`) with no
probe request; `*.databricksapps.com` is excluded because Apps serve their own
app at the root and have no workspace mount. Bounces are budgeted at one per
app-page load, so a workspace whose `/omnigent` redirects back to the root
leaves the user on the root instead of looping, and are posted to the main
looper because a `loadUrl` issued while WebView is committing a navigation can
be dropped.
- Bumps `versionName` to 0.1.3 and the local `versionCode` fallback to 9 (CI
still passes `-PversionCode` explicitly). iOS/Electron still expand to
`/ml/omnigents` behind a `server: databricks` probe; that divergence is deliberate (see the
comment in `web/electron/src/url.js`) and untouched here.
## Test Plan
- `./gradlew :app:testDebugUnitTest` for the touched classes — new
`OriginsWorkspaceUiUrlTest` (expansion, query/fragment and port/case
normalization, paths and non-workspace hosts left alone) plus new
`OmnigentWebViewClientTest` cases for the redirect nav, the POST-style landing,
in-page routing, the loop budget, and its re-arming.
- `web/android/bin/ktlint.sh` and `pre-commit run --files …` clean on the touched
files.
- Manual, API 35 emulator against a real Databricks workspace: connected with a
bare workspace URL and confirmed the shell loads `/omnigent` instead of the
workspace landing page, and confirmed via a temporary debug trace (since
removed) that in-page SPA navigations reach the new `doUpdateVisitedHistory`
hook — the callback the earlier navigation-only hooks never saw.
Note: `MainActivityTest > configuration change updates system bar icon polarity`
fails on a clean checkout of `main` as well (verified with `git stash`); it is
unrelated to this change and left as is.
## Demo
N/A — no new UI; the observable change is which URL the WebView lands on.
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Robolectric unit tests cover the URL rule and each of the three navigation
callbacks, including the loop budget. Manual verification on an API 35 emulator
against a real workspace covered the connect-time expansion and that in-page
navigations reach the new hook; the redirect-loop path (a workspace without the
`/omnigent` mount) is covered by unit tests only, since it can't be reproduced
against a healthy workspace.
## Changelog
The Android app now opens Databricks workspaces on their `/omnigent` app instead of the workspace landing page.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(claude-native): keep the agent name out of the resume transcript's model slot
An Omnigent item's wire `model` field is the agent name (MessageData.agent
serializes under that alias), not an LLM id. The synthesized Claude resume
transcript copied it straight into `message.model`, so a cold cross-machine
resume — a host switch, a fork — handed Claude Code "claude-native-ui" as a
model. Claude reported "Session model claude-native-ui could not be restored"
and silently fell back to a different model than the one selected.
Omit the field instead: there is no real model id to preserve, and an absent
one leaves Claude on its configured model.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(web): move a session to another host from the composer badge
The composer's host badge was passive: it named the machine a session was
bound to and stopped there. Moving a session meant the CLI. Clicking the
badge now opens a Switch host dialog that releases the current runner and
launches one on the host and directory you pick.
Details worth calling out:
- The move is the two calls the CLI's daemon-launch path already makes, so
there is no new endpoint. Step 1 landing without step 2 leaves the session
bound to nothing, so the dialog stays open on that failure, says plainly
that the session isn't running anywhere, and puts the origin host back in
the picker — recovering forward or back is the same click.
- The same PATCH clears the model override. A model id is resolved against
the old host's catalog, so carrying it over lands the next turn on a model
the new host may not have.
- A just-launched runner has not registered yet and no turn is in flight, so
liveness read as idle `runner_asleep` and the move landed on a silent,
empty chat. A launch marker extends the startup grace to cover it, and
lifts the failed-status suppression that tearing down the old runner can
trip.
- Host liveness is keyed by session and polled, so right after a switch it
still describes the host we left — which painted a red dot beside a machine
that is demonstrably up. A value known to predate the current binding now
defers to the host record until the poll speaks for the new host.
- Reconnect keeps the click on a disconnected host, since it has no other
entry point; the move is offered inside the reconnect dialog instead, for
owners, where waiting on a machine that may not return is a dead end.
- HostLabel moves to its own module: the dialogs that render host pickers
reference each other, so sharing it from any one of them closes an import
cycle.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): cover the host switch from the composer badge
Drives the real flow in the browser: open the badge, confirm the origin host
is not offered as a target, pick a directory, and assert the two calls the
move is made of go out in order — the release PATCH (carrying the model-
override clear) then the launch POST.
Also updates the two badge tests the switch affordance changes. Both asserted
the badge was inert whenever it had nothing to reconnect; it is clickable now,
so they assert what they were actually protecting — that reconnect is never
offered for an online host or a dormant resumable one.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): fix the host-switch test's dropdown dismissal and badge titles
Two fixes from the first CI run of the switch coverage:
- Escape with the Radix select already closed reaches the dialog and
dismisses it, so the directory field was gone by the time the test looked
for it. Pick the target option instead — that closes the dropdown and
confirms the selection in one step.
- test_hosts_changed_push asserted the badge's pre-switch title. That host is
resumable, so it is never reconnectable, and the badge now advertises the
switch affordance on hover.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): dismiss the path dropdown before submitting the switch
The suggestion dropdown under the directory field is rendered in flow, so
closing it lifts the dialog footer about 24px. A mousedown on Switch host
closes the dropdown, the button moves out from under the pointer, and the
mouseup never lands on it — the click is dropped and the dialog just sits
there. Close the dropdown and wait for it to go before clicking.
(That layout jump is real for users too, not only Playwright: edit the
directory, then click Switch host, and the first click does nothing. It is
pre-existing WorkspacePathField behaviour shared with the other host dialogs,
so it is left for its own change.)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): record turn token usage as gen_ai.usage attributes
A native Claude turn runs to completion in the terminal, not in the
harness executor: run_turn injects the message with tmux send-keys and
returns immediately, so its TurnComplete carries usage=None and the
executor adapter's `if event.usage is not None` guard never fires. The
agent span therefore closed with every GenAI semconv attribute except
the token counts, and gen_ai.usage.input_tokens / output_tokens were
missing from every claude-native trace — per-session token usage was
untrackable in MLflow.
The transcript forwarder is the one place that does see real token
counts (Claude's JSONL message.usage, or the statusLine capture), and it
already emits spans under session_scope for forwarded items. Record the
counts there with record_llm_usage, so gen_ai.usage.* lands on a span
tagged with session.id and per-session totals aggregate.
Only the token counters are recorded. context_tokens is a derived
input+cache total for the context-window gauge, and the cost-only posts
from _forward_session_cost carry no counts at all — recording zeros for
those would report a real 0-token turn on every cost tick.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): record one usage span per API call, not per poll
Addresses the AI review's snapshot-vs-delta note. The counts were taken
from `posted_usage`, which prefers the live statusLine gauge — re-read
every poll and still moving while a message streams. The post also fires
on a context-window change alone, re-sending an unchanged snapshot. Each
of those recorded another span, so a backend that sums gen_ai.usage.*
(MLflow does) multiplied the same prompt: the stated goal of a faithful
per-session total was not actually met.
Source the recorded counts from `result.latest_usage` instead — the last
COMPLETE assistant record's `message.usage`, one final figure per API
call — and dedupe them against a new `_ForwardDedupeState`
.recorded_token_usage so each figure is recorded at most once. Summing
then matches what the provider charged, since Anthropic bills each API
call's input separately.
`_post_external_session_usage` now takes the counts to record rather than
deriving them, so the cost-only call site records nothing by
construction.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(cli): stop routing loopback server traffic through HTTP proxies
A machine with an HTTP proxy configured cannot reach its own local
Omnigent server. httpx trusts the environment by default, and on Windows
getproxies() also reads the system registry, so a bare `omni` fails even
when no proxy env vars are set: the proxy resolves 127.0.0.1 against
itself.
The local-server health probe already passed trust_env=False, so URL
discovery succeeded and the very next call — sessions.create — died with
"ConnectError: All connection attempts failed". Being a transport error
it never reached the SDK's OmnigentError handling, so it escaped to the
crash handler and surfaced as a branded crash report with a traceback.
Bypass the environment's proxies for loopback targets in the SDK client
and in the CLI and runner clients that talk to the server, and turn a
refused connection into a ClickException naming the URL and the likely
fix. Remote targets keep their proxy.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(cli): bypass proxies for loopback in native harness clients too
The native harnesses (`omni --harness claude|codex|cursor|…`) never go
through the SDK client: each spawns its own local server and talks to it
over raw httpx clients. Those 21 server-bound clients still routed
loopback traffic through the environment's proxy, so the same user hit
the same ConnectError — the earlier fix only moved the failure from the
crash screen to a hang at "Launching your agent…".
Also broaden the unreachable-server catch to ConnectTimeout and
ProxyError. All three are siblings under TransportError, so catching
ConnectError alone left a remote target behind a rejecting proxy still
crashing. TransportError itself is deliberately not caught: ReadTimeout
and DecodingError are not "could not connect".
Add an AST guard asserting every server-bound httpx client decides
trust_env explicitly. The construction is copy-pasted into each new
harness, so this is what stops the next one reintroducing the bug — it
already caught a sync client in claude_native a manual sweep had missed.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(cli): point the unbuildable-proxy case at a remote server
A loopback target now bypasses proxies outright, so httpx never builds
the SOCKS transport for one and the missing-extra ImportError cannot be
reached there. Retarget the case at a remote URL, where a proxy still
applies, so the "unbuildable proxy is a transport failure, not a crash"
guarantee stays covered.
Add a companion case pinning the new loopback behavior: with ALL_PROXY
exported and the socks extra absent, the local server call reports an
ordinary refused connection rather than the SOCKS ImportError.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The native-claude launch config unconditionally set
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 on the ucode and bedrock provider
paths. That flag disables *all* experimental betas, including MCP tool
search (which rides on the `advanced-tool-use` beta). With tool search off,
Claude Code loads every MCP tool schema eagerly, inflating the context
window — for an isaac-omni session with ~187 MCP tools that is ~88k tokens
spent up front instead of on demand.
The disable flag existed to avoid the gateway 400ing on `invalid beta flag`.
But in gateway-aware mode (CLAUDE_CODE_USE_GATEWAY=1) Claude Code negotiates
the anthropic-beta set with the gateway rather than sending every flag
blindly, and the Databricks AI Gateway now accepts the flags it sends
(verified end-to-end against a live gateway: a CLAUDE_CODE_USE_GATEWAY=1
turn sends advanced-tool-use-2025-11-20 / prompt-caching-scope-2026-01-05 /
advisor-tool-2026-03-01 and completes with no 400). So the workaround is no
longer needed when USE_GATEWAY=1.
- _provider_config_for_native_claude (generic gateway path): already
guarded on CLAUDE_CODE_USE_GATEWAY (unchanged).
- _ucode_config_for_profile: this path always launches in gateway mode
(it sets CLAUDE_CODE_USE_GATEWAY=1 itself), so drop the disable flag
outright rather than guard it. Restores the pre-#4074 behavior.
- _bedrock_config_for_native_claude: add the same USE_GATEWAY guard the
generic gateway path uses, so a bedrock-style corporate gateway running
in gateway-aware mode keeps tool search on. Real AWS Bedrock (no
USE_GATEWAY) is unchanged — the flag still gets set.
Tests: update the ucode assertion, add positive coverage for the gateway
and bedrock paths under USE_GATEWAY=1, and make the env-sensitive tests
deterministic by clearing CLAUDE_CODE_USE_GATEWAY.
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
Session search (GET /v1/sessions?search_query=) matched conversation
content via LOWER(search_text) LIKE '%q%' over conversation_items, which
has no index on search_text. On Postgres/Lakebase that is a full
sequential scan; with no client- or server-side timeout the command
palette hung on "Searching…" indefinitely.
- Add a Postgres pg_trgm GIN index on LOWER(search_text) and LOWER(title)
so the existing substring LIKE is index-backed (migration d5e9f1a2b3c4,
no-op on SQLite). Verified with EXPLAIN: seq scan -> bitmap index scan.
- Bound the search query server-side with SET LOCAL statement_timeout
(Postgres only) so a degraded deployment fails fast instead of pinning
a connection from a worker thread a client disconnect can't stop.
- Bound search fetches client-side with AbortSignal.timeout and skip
retrying a client timeout, so the palette settles to a terminal state
instead of an endless spinner.
Search results are unchanged with or without the index.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(openai-agents): don't route an unpinned model to Databricks auth
`_get_openai_async_client` treated an unpinned model as Databricks-hosted
(`model is None or model.startswith("databricks-")`). An openai-agents agent
with no pinned model and no OpenAI credentials therefore fell through to the
ambient Databricks fallback and failed with "The 'databricks-sdk' package is
required for Databricks authentication", or DatabricksAuthError when the SDK
was installed, at users who never configured Databricks.
An unpinned model means "use the provider's default", which `run_turn` already
resolves from the model catalog. Gate the ambient Databricks fallback on an
actual Databricks signal instead: a `databricks-` model name or an explicit
profile. Both of those paths are unchanged, so real Databricks deployments
still resolve as before. The no-signal case now raises the existing
OpenAI-credentials ValueError, which names the real problem.
Also reword that error for the unpinned case, which previously read
"for model None".
Reported-by: Abhay Singh <abhay-codes07@users.noreply.github.com>
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* test(openai-agents): assert the full Databricks base URL
CodeQL flagged the substring check `"example.databricks.com" in
str(client.base_url)` as py/incomplete-url-substring-sanitization
(high): a host substring can appear anywhere in a URL, so the pattern
is unsafe to copy even in a test.
Compare the whole URL instead, which also pins the gateway path the
ambient Databricks path is expected to build.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
A harness subprocess configured no logging at all, so its root logger had no
handler and Python fell back to logging.lastResort: WARNING+ went to whatever
stderr it inherited, and everything below was dropped. The spawn passes
stdout=stderr=None, so even that survivor went to the runner's stdio rather than
into the log tree.
The effect on ACP: an agent CLI's own stderr is drained at debug level and every
executor logger.exception is emitted below WARNING, so both vanished. A failing
turn reported one line with no traceback and no agent output anywhere on disk,
which is why diagnosing the blank-error bug needed stdio-level access to the
agent instead of a log file.
_runner now configures logging before loading the harness app, so this covers
every harness, not just ACP. It reuses OMNIGENT_PROCESS_LOG_FILE when the parent
published one (harness lines then interleave with the spawn that caused them),
otherwise allocates logs/harness/<harness>-<conversation>-<ts>.log. Failure to
set up logging prints to stderr rather than raising: diagnostics must not stop a
harness serving turns, but must not be silent either, since a silent failure
looks exactly like the bug being fixed.
Second, an ACP startup failure now quotes the agent's own explanation. The
executor keeps the last 20 stderr lines and appends the trailing few to the turn
error, alongside the log path. A stalled handshake named only the RPC that timed
out; the reason is almost always on the agent's stderr.
Before: inner executor error: ACP agent 'Grok Build' did not answer session/new
within 30s (command: 'grok agent stdio')
After: ... ; Grok Build stderr: ERROR: XAI_API_KEY not set; cannot authenticate
| hint: export XAI_API_KEY or run `grok login` (harness log:
~/.omnigent/logs/harness/acp-conv_ab12-20260810-173203.log)
Both the ring and the quoted tail are capped so a chatty agent cannot push an
enormous line into a UI toast.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(server): ride out transient runner-tunnel drops with a reconnect grace
Tunnel drops from ingress recycles and laptop sleep-wake re-register the
runner in well under a second, but the server failed every bound session
and killed the turn-event relay the instant the socket died. Hold the
failed-marking behind a 5s grace that a re-registration cancels, and let
the relay retry its stream inside that window. Intentional stops and
daemon-reported crashes still surface immediately.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(server): widen the runner-disconnect grace to 10s
The worst observed ingress-recycle burst (~5s of failed reconnect
attempts) sat exactly at the old value's edge. Double it for headroom:
transient drops get more room to resolve silently, while silent
(non-crash-reported) runner deaths surface 5s later. Crash-reported
deaths still bypass the grace and fail immediately with their cause.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): recover managed-mint auth when a re-mint 403s after JWT expiry
A managed runner whose owner JWT fully expires (an idle session crossing
the 60-minute token lifetime) re-mints with that expired JWT as its own
proxy bearer, and the Apps edge answers 403. The 401/403 branch only
latched proxy_auth_failed when no mint had ever succeeded, so this state
set neither latch and _RunnerDatabricksAuth.auth_flow raised
httpx.RequestError("Databricks token refresh returned no token") on
every callback for the remaining life of the process — event forwarding,
policy evaluation, and cost/status were all dead until restart
(OMNI-2529, #4332).
- _ManagedMintTokenFactory: latch proxy_auth_failed on a mint 401/403
whenever no still-valid cached token remains, not only before the
first successful mint. Inside the refresh-skew window the still-valid
cache is served without latching, as before.
- _InitialAuthTokenFactory: consult proxy_auth_failed after invoking the
fallback rather than before, so the request that hits the 403
re-resolves SDK/OIDC in the same call instead of failing once and only
healing on the next.
Covered by a timeline unit test on the latch, a same-call re-resolve
unit test, and an e2e test replaying the full deadlock against a live
accounts server behind a mint-403ing Apps-edge stand-in.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): address review — constrain e2e proxy targets, log missing-credential once
- e2e Apps-edge stand-in: relay only origin-form /v1/... request targets and
rebuild the forwarded URL from path+query against the fixed upstream base,
so an absolute-form target can never override the forward client's
base_url (resolves the CodeQL full-SSRF finding).
- _InitialAuthTokenFactory: the no-SDK/OIDC-credential state is terminal for
the process, so log the re-auth guidance once instead of on every
callback (Polly review note).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): keep the message when an attachment upload is rejected
Attaching an unsupported file (a .zip) on the new-chat landing screen
created the session, navigated into it, and only then failed the first
turn with a bare "upload failed: 415" — the typed message was gone and
there was nothing left to retry.
- Validate attachments on the landing composer (paperclip, drop, paste),
so an unsupported or oversized file is refused before a session exists.
Only the in-session composer did this before.
- Hand a failed send's text and files back through `failedSendDraft` so
the composer can restore them; nothing else holds the message once the
optimistic bubble rolls back and the pending prompt is consumed.
- Surface the server's reason instead of the status line: read FastAPI's
`{"detail": ...}` shape alongside `{"error": {...}}`, and throw an
ApiError from uploadFile.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): clear the attachment rejection notice as the user types
A rejected attachment is never added to the composer, so there is no
chip to remove and nothing else ever cleared the notice. It sat under
the composer permanently and read as a hard blocker, leaving no obvious
way to just send the message without the file — even though submit was
never actually gated on it.
Clear it on the next keystroke in both composers, matching how the
in-session composer already clears `commandError`.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): cover landing-screen attachment rejection and failed-send restore
The existing suite covered the in-session composer rejecting an unsupported
type, but not the two flows this change is actually about:
- The landing composer rejecting a zip without losing the typed message, and
without creating a session. This is the case that bit users; it can't be
reached below the browser because it depends on the real hidden file input
and on no navigation happening.
- A send whose upload fails handing the message back to the composer. The
failure is injected at the network boundary (415 with the server's real
body) rather than with an unsupported file, since client-side validation
would reject that before any request and never exercise the path. The body
also pins that the banner carries the server's reason rather than a bare
status line.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): note failedSendDraft's last-failure-wins semantics
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
`_host_http_json` caught `httpx.HTTPError` and `OSError`, but httpx raises
`ImportError` while constructing the client when the ambient environment
selects a SOCKS proxy and the optional `socksio` extra is absent. That
escaped the daemon-reuse probe and crashed every command that ensures the
backend for users whose shell exports `ALL_PROXY=socks5://...`.
Treat it as the transport failure it already models, so the host reads as
unreachable and the daemon heals instead of the command aborting.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(inner): never report a blank turn error from ACP-style executors (#4281)
Every generic-ACP turn that hit an exception surfaced to the operator as
`{"code": "runner_error", "message": "inner executor error: "}` with an
empty message. The ACP / Goose / Qwen executors reported failures from
their stdout reader via `str(exc)`, which is empty for several stdlib
exceptions raised without a message (a bare `RuntimeError()`,
`TimeoutError()`, etc.), so the turn failed with no stated reason.
Add a shared `describe_exception` helper in `inner/executor.py` that falls
back to `repr(exc)` (which always names the exception class) when
`str(exc)` is empty, and use it at all three reader error paths
(`acp_executor`, `goose_executor`, `qwen_executor`). Also harden the
harness adapter so an `ExecutorError` with an empty message from any other
path still yields a non-blank "inner executor error" instead of a
trailing-blank string.
This is the reporting half of #4281 (the turn error is never blank again);
the underlying per-agent failure, previously invisible, now names at least
its exception type.
Tests: `describe_exception` falls back to repr for a bare exception,
preserves a real message verbatim, and is never blank for a range of
stdlib exceptions.
Signed-off-by: abhay-codes07 <abhaysingh0293@gmail.com>
* fix(inner): name the exception at every executor turn-error path (#4281)
The same blank-message pattern the reader paths had also lives in every
executor's `run_turn` failure path: `yield ExecutorError(message=str(exc))`
goes blank for a bare exception. The adapter guard added in the previous
commit already stops a blank from reaching the operator, but it can only
fall back to a generic "no detail" string. Routing these 15 sites through
`describe_exception` names the actual exception type instead, across all
harnesses (claude-sdk/native, codex, cursor, antigravity, goose, hermes,
kimi, kiro, openai-agents, qwen, acp).
Mechanical, single-helper change; covered by the `describe_exception`
unit tests and the executors' existing run_turn tests.
Signed-off-by: abhay-codes07 <abhaysingh0293@gmail.com>
* style: drop trailing whitespace left by the main merge
The main-merge resolution left a whitespace-only line where this branch's
describe_exception tests meet the spawn-env tests that landed on main, failing
the ruff-format pre-commit hook.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: abhay-codes07 <abhaysingh0293@gmail.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Dhruv Gupta <dhruv0811@gmail.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(copilot): honour the gh CLI login and support a GitHub Enterprise host
Launching a copilot-harness session after `gh auth login` failed with a 401
even though the user was logged in, and organizations reaching Copilot through
a GitHub Enterprise (data-residency) instance had no way to point auth at their
own host.
The Copilot CLI does honour a `gh` login, but only by reading `oauth_token`
straight out of `~/.config/gh/hosts.yml`. Whenever `gh` stores the token in an
OS keychain instead (the default on macOS) that field is absent, so a logged-in
user looks credential-less and session creation fails. Asking `gh auth token`
works on every platform, so it becomes the last fallback in the executor's
ambient-token lookup: the single chokepoint both the in-process executor and the
harness wrap route through. Readiness gained the same fallback so setup stops
asking for a token that `gh` already holds.
Note the SDK is not at fault here: it already derives `use_logged_in_user` as
`not bool(github_token)`, so a `None` token resolves to True on every
connection path.
For Enterprise, the SDK exposes no host parameter, but the bundled CLI reads
`COPILOT_GH_HOST` (which overrides `GH_HOST`, so a user's `gh` host is left
alone). A new `copilot.github_host` config field, settable from `omnigent
setup`, is threaded through the spawn env to the executor and exported for the
CLI to inherit. It is applied before the token is resolved so a GHE user's `gh`
token is fetched from their own instance. The env var is set on our own
environment rather than passed as `env=`, because the SDK inherits `os.environ`
only when that argument is None.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Isaac
* fix(copilot): keep the GHE host when removing the stored token
Addresses the Polly review on #4396.
`Remove GitHub token` unset the whole `copilot:` config block, so it also
dropped a configured `github_host` — silent data loss, and it defeated the
field preservation the two settings savers were reworked to guarantee. Removal
now rewrites the block with just the host it must keep, and only unsets the key
outright when there is nothing left to preserve.
Also close the stale-host hazard the same review flagged. The executor writes
`COPILOT_GH_HOST` to hand the host to the bundled CLI, and host resolution read
that same var back, so a hostless executor could inherit a host an earlier one
left behind. Resolution now reads the ambient value captured at import instead
of the live var, and a hostless session clears the var rather than leaving a
previous value in place.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Isaac
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The 'Archiving...' spinner was cleared in the archive mutation's onSettled, i.e. the moment the PATCH resolved. But the row only leaves the sidebar a round-trip later, when the ["conversations"] refetch drops the archived row. That gap flashed the row back to its plain, clickable form with no spinner while the session was still listed.
Keep the spinner mounted until the row itself unmounts: don't clear isArchiving on success (the row and spinner leave together when the refetch removes it); only clear on error so the interactive row returns for a retry.
Adds an e2e-ui regression test that freezes the window between PATCH-resolved and row-gone (holds the list refetch, swallows the updates WS) and asserts the spinner persists; updates the Sidebar.archive unit test to the new contract.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
This reverts commit 540e31b500 (PR #4501).
The fix filtered the agent->conversation reverse lookup on
parent_conversation_id IS NULL, assuming a session-scoped agent's owning
conversation is always top-level. That assumption is false: a bundle can be
uploaded as a child via multipart POST /v1/sessions with parent_session_id set
(_create_bundled_session_from_multipart -> create_session_with_agent with a
non-null parent_conversation_id). For such a child-minted agent, every row
sharing its agent_id has a non-null parent, so the filter matches nothing and
_session_id_for_agent returns None. agent.session_id then resolves to None and
validate_session_agent SKIPS the owning-session READ check entirely -- a
correctness and access-control regression worse than the original 404.
Reverting to restore the prior behavior while a discriminator that also covers
child-minted session-scoped agents is designed. OMNI-1611 remains open.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(stores): resolve session-scoped agent to its owning session
Named sys_session_send children are created bound to the same agent_id as
their parent, so _session_id_for_agent's unordered LIMIT 1 could return a
child conversation. The owning-session auth check then ran against a row not
yet visible on a read replica, surfacing as a spurious 404. Filter on
parent_conversation_id IS NULL to return the unique owning session
deterministically.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: drop e2e reproduction with wrong fixture premise
The archer fixture declares fact_checker/summarizer as type:agent tools, not
top-level sub_agents, so a named POST /v1/sessions with sub_agent_name is
rejected by _require_declared_subagent at child #1 — archer cannot reproduce
OMNI-1611. The deterministic unit test in tests/stores/test_agent_store.py
covers the fix across sqlite/postgres/mysql; drop the misleading e2e test.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): bring back composer footer chevrons and add pointer cursors
Restore the down chevrons on the landing composer's footer chips (working
directory, host, sandbox repo, git worktree) that #4225 removed, and add
cursor:pointer to every dropdown/select trigger in the dialog.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test(e2e-ui): regenerate visual baselines
---------
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* Sidebar peek
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* fix(web): make Sidebar onOpen optional so it renders standalone in tests
The peek work made onOpen a required prop, but the Sidebar.*.test.tsx
harnesses don't pass it, breaking the typecheck. Mirror the onOpenSearch
convention: optional with a no-op default.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* Test fix
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* bugfix
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* bugfix
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
---------
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Harness terminal-exit failures surfaced as a terse code (e.g.
`required_terminal_exited`) over a raw, sometimes mid-word-truncated PTY
tail — hard to act on. Add one shared classification layer on the common
terminal-exit path so every harness benefits:
- Capture the inner process exit code from tmux `#{pane_dead_status}` and
thread it through `TerminalExitEvent`.
- Fix output truncation to drop whole leading lines instead of slicing
mid-word.
- New `omnigent/runner/launch_failure.py`: declarative matchers →
`FailureDiagnosis(title, cause, remediation)` (root+skip-permissions,
not-authenticated, missing-binary) plus a code→sentence table.
- Carry optional `title`/`cause`/`remediation` on `ErrorDetail`, through the
`session.status: failed` SSE event and durable labels, so a reload renders
the same card. The composed `message` still works for older clients.
- Frontend: `ErrorBanner` renders a friendly card (headline, cause,
remediation, folded details) with a code→sentence fallback.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): expand ~/ paths in the workspace picker
The picker only resolved the host home dir from the empty home view, so
when it opened at an absolute initialPath (the new-session flow) a typed
~/foo path could not be expanded and silently reverted to the current
directory. Resolve home from a dedicated listing independent of where the
picker is browsing, so ~-relative paths expand from any starting point.
Covered by a new e2e_ui start_session test.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): show an error for a nonexistent path in the workspace picker
A typed path the host 404s on left the picker showing the previous valid
directory's contents: the filesystem query kept the old listing on screen as
placeholder data while it retried the deterministic 404, so nothing signalled
the path was bad. Skip retries for 4xx so the error surfaces immediately, and
throw a friendly doesn't-exist message naming the path instead of a bare
status code.
Covered by a new e2e_ui start_session test plus useHostFilesystem unit tests.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): standardize Codex bypass UX on Claude's — drop the danger banners
Codex was the only harness that surfaced its most-permissive stance
(--dangerously-bypass-approvals-and-sandbox) with two red role=alert danger
banners: one inside the config modal under the Approval row, one pinned under
the composer that survived the modal closing. Claude's equally-permissive
bypassPermissions has neither — it's a plain dropdown option whose blurb rides
in the DescribedSelect footer, with the armed stance read back via the gear
tooltip.
Standardize Codex on that pattern: remove both banners so every harness
surfaces its stance the same way. Bypass stays the 4th Approval option and the
gear tooltip still reads back 'Approval: Bypass approvals & sandbox', so the
dangerous stance remains visible before create — just not shouted. The label
plumbing is untouched, so the runner still receives
omnigent.codex_native.bypass_sandbox=1.
Update NewChatDialog unit/flow tests and the start_session e2e to assert the
standardized shape (footer blurb tracks hover, trigger reads back, no alert-role
node) instead of the removed banners.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
A prompt sent to a resuming claude-native session sometimes never reached the
Omnigent DB while still showing in Claude's TUI pane — no error, no warning.
`start_at_end=True` means "skip the prefix I just wrote" — it is set iff this
launch synthesized a resume transcript from committed Omnigent history (which
the DB already has, so forwarding it would duplicate the conversation). But it
was implemented as "skip whatever exists when I get around to looking", and
those are different things. Seeding requires `transcript_path` from Claude's
first hook, and `inject_user_message` waits on the same boot; the two are
unordered, so the paste routinely wins. Everything Claude wrote in that
window — the user's prompt included — then sat behind the cursor, skipped for
the session's lifetime.
The prefix length is already known before launch: all three synthesizing paths
(`_ensure_local_claude_resume_transcript` on cold resume, `_clone_claude_transcript`
for a same-host fork, the items-rebuild for a cross-family fork) return the path
they wrote. Measure it there and pass `start_at_offset` through instead of
relying on a later `stat`. The skip becomes exactly the prefix regardless of
when the forwarder is scheduled, so the race is removed rather than narrowed.
`start_at_end` stays for reattach, where nothing was synthesized and a live
end-offset is correct — the CLI attach path has no concurrent inject. The
offset is clamped to the transcript end so a truncated/replaced file cannot
leave the cursor past EOF, and a failed measurement falls back to the old
behaviour rather than to 0 (re-forwarding all history is the worse failure).
claude-native only: `supervise_forwarder` here is distinct from the same-named
codex function, and no other harness forwarder has `start_at_end`.
Co-authored-by: Isaac
* fix(acp): let a generic-ACP agent declare the env vars it authenticates with
A generic-ACP agent configured the documented way (an `acp.agents:` row, or
`omnigent setup` -> Custom ACP agent) was spawned with no provider credentials
and no way to be given any, so it started unauthenticated, stalled during the
handshake, and every turn failed.
The spawn env is deny-by-default with an empty prefix family: the executor
drives an arbitrary agent, so it cannot know which vendor family that agent
authenticates with, and guessing would re-widen the leak that filtering closed.
That part is right. The gap was the escape hatch: `env_passthrough` only existed
on a full agent spec's `os_env.sandbox`, which a user configuring an agent
through `acp.agents:` never authors. Measured against a realistic environment,
only HOME/PATH/TERM survived.
Keep deny-by-default and make the hatch reachable per agent:
acp:
agents:
- name: Grok Build
command: grok agent stdio
env_passthrough: [XAI_API_KEY]
Names only, never values: the variable is read from the host environment at
spawn, so no secret lands in config.yaml. A `NAME=value` entry is rejected
rather than accepted-and-ignored, since that mistake would write a plaintext
credential and still not reach the agent. Threaded through the existing
plumbing (AcpAgentEntry -> HARNESS_ACP_ENV_PASSTHROUGH -> AcpAgentConfig ->
_build_spawn_env), unioned with any spec-declared names, and also honored for a
spec-embedded one-shot agent.
Also stop the handshake timeout reporting itself as a blank failure.
`asyncio.TimeoutError` carries no message, so a caller reporting it by
`str(exc)` produced `inner executor error: ` with nothing to act on. `_rpc` now
raises a TimeoutError naming the agent, the stalled method and the deadline, at
the one place every handshake RPC routes through.
Before: `inner executor error: `
After: `inner executor error: ACP agent 'Grok Build' did not answer
session/new within 30s (command: 'grok agent stdio')`
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(acp): keep the spawn-env canary working with the agent-declared allowlist
The canary drives the real `_build_spawn_env` on an executor built via
`object.__new__` carrying only the attributes the builder reads, so reading
`self._config` unconditionally raised AttributeError there. Read the agent
config defensively, matching the duck-typed style `declared_passthrough`
already uses for the spec chain.
Also extend the canary to the new field: a declared name is an allowlist, not a
bypass, so the declared variable arrives and every planted canary secret still
stays out.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The bundle injector resolved its source directory by counting parents off
its own module file. When native terminal orchestration was extracted from
the runner app into its own subpackage, the module moved one level deeper
and the parent count came along unchanged, so the path resolved to a
directory that does not exist. The is_dir guard then returned on every
call, silently, injecting nothing into any bundle.
Nothing landed in the bundle's skills directory, so build-omnigent was
not discovered by Claude Code via --plugin-dir, not discovered by Codex
(whose skill-source resolution only returns the bundle root when that
directory exists), and never reached the user-invocable slash-command
menu. The MCP load_skill path was unaffected: it is served by a sibling
injector that did not move.
Anchor on the package root instead of a parent count, so relocating this
module cannot break the path again, and log the missing-source branch so
the next such regression is visible rather than silent.
Add regression coverage: nothing referenced this function before, which
is why the breakage shipped. The tests assert the observable outcome (the
skill lands, and the real Codex resolver finds it) rather than the path
expression. Verified they fail on the pre-fix code and pass after.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(cli): guard headless -p turns against a lost terminal SSE event
_query_sessions_once's first-turn chat.query(prompt) call had no
timeout, so a specific variant of the documented subscribe-after-post
race (see the surrounding comment on _persisted_turn_text) could hang
the CLI indefinitely: the runner completes and persists the turn
server-side, but the client's no-replay SSE subscription misses the
terminal response.completed event. Unlike the two already-handled
variants (an OmnigentError from a runner disconnect, or a clean return
with empty text), this one raises nothing and never returns — periodic
session.heartbeat events keep the stream's async iterator busy
indefinitely, so the loop just waits forever for a terminal event that
will never arrive.
Wrap the first-turn query in asyncio.wait_for using the same
_PER_TURN_TIMEOUT_S race-window guard already applied to the
multi-turn synthesis loop later in this function, and on timeout fall
through to the same _persisted_turn_text reconciliation already used
for the other two variants of this race.
Root-caused by manually replaying the codex app-server JSON-RPC
protocol (confirming the protocol and CodexExecutor are both correct),
then instrumenting the runner scaffold and server SSE route to show
the runner always yields a correct terminal event and the session
always reaches "idle" server-side, even on client hangs.
* fix(cli): make the headless first-turn guard status-aware
The wait_for guard alone cannot tell a lost terminal event from a
healthy turn that simply outlasts it. The server persists assistant
items incrementally, so reconciling straight away returns a mid-turn
fragment as the final answer (silent truncation) for any first turn
longer than the guard window, and raises for one with no output yet.
On timeout, keep waiting while the session still reports the turn in
flight, mirroring the extra-turns loop's refresh-and-continue, and
reconcile against the durable transcript only once the session is no
longer running. Hoist the shared timeout constants to module level so
tests can patch them, and cover the lost-event, no-output, and
slow-turn paths.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
A runner stop or disconnect empties the terminal list; landing while the
terminal view was open stranded the user on 'No terminals available.' with
the Terminal toggle greyed out. Flip terminal-first sessions back to chat on
that edge, where the composer can resume the session. Edge-triggered and
guarded on terminalStartingUp so a cold boot or relaunch isn't yanked.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
test_build_startup_header_creds_line_hints_first_available asserts the openai
surface with no default falls back to a configured Databricks workspace. On a
dev machine running a local Ollama, ambient detection (a hardcoded
localhost:11434 TCP probe) injects an openai-serving provider that outranks
Databricks, so the creds line read "Codex → Ollama" and the test failed —
while CI (no Ollama) passed. Pin detect_providers to none so the test
exercises config-order fallback deterministically.
(cherry picked from commit 8b0d6eeb23d057c1657524f637bb3248c9d2483c)
Signed-off-by: apeltekci <andrew@peltekci.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
_session_snapshot deliberately refuses to cache an incomplete or failed
snapshot so spec resolution can retry until the agent binds. The workspace
projection cache defeated that: both _session_workspace_value and
_ensure_session_registered wrote snapshot.workspace unconditionally, so a
single transient non-200 pinned workspace=None for the session's lifetime.
_session_runtime_cwd then returned the global runner workspace instead of
the session's worktree, and the harness process manager bakes the
subprocess env at first spawn, so the session never recovered. Nothing
short of deleting the session cleared it: the reset-agent-cache path only
evicts _session_snapshot_cache, not the projections.
Guard both writes on snapshot.ok. created_at stays unconditional in
_ensure_session_registered because its wall-time fallback is documented
behavior there.
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
* fix(web): keep a selected row's title clear of its "Needs response" tag
The tag is absolutely positioned, so the row's right padding is the only thing
holding the title clear of it. That reserve narrows to make room for the trailing
pin/kebab -- but it narrowed on `group-focus-within`, while the tag fades (and the
controls appear) on `group-has-[:focus-visible]`.
`focus-within` matches a plain mouse click; `:focus-visible` does not. Clicking a
row therefore cut the reserve from 116px to 56px with the tag still fully opaque
and the controls still hidden, sliding the title 59px underneath it. The tag
surface is translucent, so the collision reads as a washed-out opacity glitch
rather than the layout problem it is.
Key the reserve on `group-has-[:focus-visible]` so it narrows exactly when the
tag fades and the controls appear -- the three can no longer disagree about
whether that space is free. Measured on the selected row: +59.4px of overlap ->
-0.6px, with the idle row's title width byte-identical (120px at every interface
font size), so nothing truncates earlier than before.
Note this is the selected-state defect only. A row at interface font 15px+ still
overlaps in *every* state, including idle, because the 116px reserve is fixed
while the tag's width tracks the font size; that is a separate pre-existing bug
and is left alone here.
Covered two ways: a unit test pinning that the reserve and the tag's fade share
their triggers (the class-level contract), and a Playwright test measuring the
real painted glyphs against the tag's edge after a click (jsdom reports every box
as 0x0, so geometry needs a browser). Both were confirmed to fail with the
`focus-within` trigger restored.
Also repoints the Inbox count bubble from the shared amber `--warning` to
`--brand-accent`, matching the pink the tag and unread dot already use.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Co-authored-by: Isaac
* test(ui-snapshot): update the populated-sidebar baseline for the pink Inbox badge
Regenerated in the digest-pinned Playwright image the gate renders in, so the
bytes match what CI compares against.
Only the populated-sidebar baseline drifts; the other four visual snapshots
render identically. The diff is a single 16x16px region at (288,118) -- the Inbox
count bubble, amber (218,164,71) -> brand pink (227,87,150). Nothing else in the
1280x800 frame changes, and the row-reserve fix contributes no pixel delta here
(the fixture's awaiting row is idle, whose geometry is unchanged).
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Co-authored-by: Isaac
---------
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(cli): make no-AGENT `run --server ""` select local mode
`omnigent run --server ""` is documented as the way to "auto-spawn a
persistent local server ... instead of a remote one". It worked when an
AGENT was passed, but the bare no-AGENT form failed with:
Error: Agent path not found: https:
With no AGENT, `target is None`, so `_dispatch_run` takes the no-AGENT
direct-server branch. That branch gated on `server is not None` rather
than truthiness, so `""` reached `_resolve_server_url("")` and normalized
to the bare scheme `"https:"` — `_with_default_scheme("")` returns
`"https://"`, which the trailing-slash trim reduces to `"https:"`. That
string is not `_is_url`-shaped (no `//`), so it was passed as
`run_chat(target=...)` and died as a missing agent path. With an AGENT the
branch is skipped entirely and `""` flows to `_ensure_backend`, which
already reads it as local mode via a truthy `if server:`.
Treat an explicit empty `--server` as the local-mode request it is:
collapse it to the `None` sentinel `_ensure_backend` understands, and keep
the config fallback from putting a configured remote back in its place.
Both gates now test truthiness, and `_resolve_server_url` rejects an
empty/whitespace-only value outright rather than inventing a nonsense URL.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Isaac
* feat(cli): accept `--server local` as a readable local-mode alias
`--server ""` was the only way to say "ignore any configured remote and run
against a local server", which is hard to discover and easy to mistake for a
missing value. Accept the literal `local` as an alias for it.
`local` is already this codebase's name for the mode — `_LOCAL_DAEMON_MARKER`
is the marker local mode records in host.pid, where "real URLs never collide
with the marker". Neither spelling can be a genuine target: an empty value has
no host, and a bare `local` would normalize to the unroutable `https://local`.
Both spellings now route through one `_is_local_server_request` helper, matched
case-insensitively on the whole trimmed value — so `localhost:8000` and
`http://localhost:6767` keep their normal explicit-server behavior.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): remember the sidebar's session filter across reloads
The Sessions heading's filter menu ("All sessions" / "My sessions" /
"Shared sessions" / "Archived sessions") kept its pick only in React
state, so every reload snapped the list back to "All sessions" — a
viewer who works out of "My sessions" had to re-pick it after each
refresh.
Persist the pick to localStorage and seed the sidebar's state from it,
matching the other `*Preferences` helpers (and the sidebar's own
collapsed-section / expanded-project state). Writing it inside
`switchTab` keeps the documented single funnel for tab changes, so the
"New session" snap-back to "My sessions" is remembered too.
A stored value is validated on read: an unknown filter, or "shared" on
a loopback-only server where the menu drops that option, falls back to
"All sessions" rather than scoping the list to a slice the viewer has
no menu entry to leave.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* test(e2e): cover the sidebar session filter surviving a reload
The E2E UI Required gate asks for a tests/e2e_ui/** test whenever web/**
changes user-facing behavior; the filter-persistence fix shipped with
unit/component coverage only.
Adds three Playwright tests against a live server:
- "My sessions" still scopes the list after a full page reload, asserted
both by the shared row staying out and by the radio item reading
checked, so a list that happens to look right can't pass.
- The Shared filter round-trips too, proving the write isn't
special-cased to "mine" (it hangs off the single tab-change funnel).
- A stored "shared" is dropped on a loopback-only server, where the menu
omits that option — seeded via add_init_script so the value is in
storage before any app script runs, as a returning viewer's first
paint would see it.
The first two fail on a build without the seed (the filtered-out row
reappears after reload) and pass with it, so they pin the actual
regression rather than the current rendering.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
---------
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
The header switcher hid both destinations behind a dropdown: a
MessagesSquare + chevron trigger you had to open before you could see
which view you were in or switch to the other one. Reading the current
view took a hover (the tooltip), and switching took two clicks.
Replace it with a two-segment icon toggle in a shared track. Both
destinations are always on screen, the active one is filled, and
switching is a single click. Sits in the same header slot, immediately
left of Share, at the same 32px scale as the neighbouring controls
(size-6 segments in a p-0.5 track).
Behavior is unchanged: the same TerminalFirstContext drives it, it
self-gates for non-terminal-first sessions, the iOS shell (native
Liquid Glass bar), and rail-opened shell views, and Terminal stays
disabled — with a spinner while a PTY is coming up — until one is
reachable. Each segment carries aria-pressed and a tooltip naming it,
so the icon-only control stays legible to pointer and AT users alike;
the Terminal tooltip doubles as the "starting up" explanation.
Collapsing the menu drops the machinery it needed: the controlled
tooltip (two merged Slots on one node dropped its listeners), the
pointer-vs-keyboard close-refocus ref, and the e2e open-retry loop
that existed because a toggle-trigger click could net back to closed.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
A `claude-sdk` agent with `sandbox.type: linux_bwrap` died at every session
spawn with:
bwrap: Can't create file at /tmp/claude-<uid>/<proj>/<sess>/tasks/<id>.output:
No such file or directory
The dotfile / escaping-symlink masker emitted `--bind-try /dev/null <path>`
for every non-directory entry. bwrap resolves a mount destination *through*
a final symlink, so when the entry is a symlink both mask shapes abort the
whole namespace (`Can't create file at <link>` for the file shape,
`Can't mount tmpfs on <link>` for the dir shape) and the launcher exits
non-zero, surfacing as an opaque Claude SDK connect timeout.
The claude CLI links `tasks/<id>.output` into `~/.claude/projects/...`,
which escapes the safe-root set, so the walker flagged it and the emitter
produced a mount aimed at the link.
Skip symlink entries instead. This is safe because the mount namespace
already confines symlink resolution: the link is followed inside the sandbox
view, where an escaping target is either not mounted or independently
masked. Verified against bwrap: reads through a symlink to a masked dotfile
and into a masked dotdir both return empty with no mount on the link.
Not claude-sdk specific. The cwd pass always runs and `linux_bwrap` is the
Linux default, so any escaping symlink in an agent workspace hit this.
`darwin_seatbelt` shares the walker but emits path-based SBPL literals and
is unaffected.
The prepare-time degrade from #2749 could not catch this: `wrap_launcher_argv`
only builds argv and never executes bwrap, so a mount-time failure is
invisible to it.
Closes#3265
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Every runner-spawned context lost the ssh-agent socket, so any agent doing
git-over-SSH or SSH-cert-authenticated tooling failed with "dial unix:
missing address" (often surfacing as a confusing 401 from the endpoint,
since such tools have no cached-token fallback).
Two independent gates dropped it:
- `_build_runner_env` filters the host env through `_RUNNER_ENV_ALLOWLIST`,
which omitted SSH_AUTH_SOCK. This is also the list both host-daemon modes
consult, so the one entry fixes the daemon hop too, including remote mode.
- `clean_agent_env` is the shared deny-by-default filter for every vendor
CLI, and its safe base omitted it. Fixing the shared base covers all
seven harnesses rather than only the one whose report surfaced this.
Classified as a path, not a bearer secret: it names a unix socket, and
reaching the agent behind it still requires the user's own ssh-agent to be
running and holding the key. Same footing as KUBECONFIG, already allowlisted.
An ACTIVE OS sandbox deliberately keeps excluding it: that boundary exists
to confine the agent, and signing with the user's keys is what it confines.
`os_env.py` previously justified its exclusion by calling the variable "a
credential surface masquerading as a path", which contradicts the
classification above; that rationale is rewritten to rest on the sandbox
boundary instead, so the codebase states one position.
Downstream paths needed no change: `sys_os_shell` (sandbox inactive) and
`sys_terminal_launch` both mirror the parent env, so they inherit the fix.
Codex's `shell_environment_policy.inherit` was reported as a third gate
requiring omnigent to force `inherit="all"`. It does not reproduce: on
codex-cli 0.144.3 the default already passes SSH_AUTH_SOCK through
(identical 72-var env), and only an explicit `inherit="core"` drops it.
Forcing `all` would override that deliberate user choice, so no override
is added.
Co-authored-by: Isaac
* fix(cli): accept a copied conversation URL as a server, and stop the SPA mislabeling missing API routes
A conversation link copied from the browser (`<host>/c/<id>`) is what a user
naturally pastes when asked for their omnigent URL, and `omnigent login` stored
it verbatim as the default server. `/c/<id>` is a client-side SPA route, so
every later API call was addressed under it and matched no router. A bare
`omni` then crashed at session-create, on a machine the user never pointed at a
remote by hand.
Nothing caught the bad URL earlier because the web UI is mounted at `/` and
answers any unmatched GET with its HTML shell: `GET <base>/c/<id>/v1/me`
returns 200, so the login probe reads it as header-auth mode and persists it,
and `/health` passes too. The first request that needs a real route is the
session create.
That failure then reported `405 Method Not Allowed`, because StaticFiles serves
only GET/HEAD and raises 405 for anything else. The body is identical to
FastAPI's path-matched-wrong-method response, so the error reads as "this
endpoint exists, you used the wrong verb" and points at the server instead of
the URL.
- Trim the `/c/<id>` route in `_resolve_server_url`, the chokepoint every entry
point already normalizes through, so an existing stored link is repaired on
the next run rather than needing a hand-edited config.
- Answer 404, not 405, for anything reaching the SPA catch-all: nothing that
gets there exists, and a non-GET is never an SPA navigation.
- Report a failed session create as a ClickException naming the URL, which the
function's docstring already promised; the raw client error was reaching the
crash handler as a traceback.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(cli): address review notes on the conversation-URL trim
- Return the rstripped URL on the no-match path too, so both branches of
strip_conversation_path normalize a trailing slash identically.
- Reword the session-create guard's comment: it covers fork and resume
rejections as well, not only a wrong base URL.
- Pin the OPTIONS case in the catch-all test. No CORS middleware is
installed, so a preflight reaching the SPA mount was already a 405 no
browser could use; 404 is more accurate rather than a lost capability.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
A custom agent spec carrying executor.auth or a legacy profile routed fine in-process but was invisible to resolve_native_codex_launch, so the native TUI fell to the Codex login screen and timed out. Thread the spec through and resolve it with _resolve_provider_for_build, the same resolver the in-process harness uses; machine-level flows are unchanged when no spec credential is present.
Signed-off-by: Andrew Demczuk <andrew.demczuk@gmail.com>
* fix(web): stop a stalled POST from wedging every send in the tab
A send whose POST never settles (postEvent issues its fetch with no
timeout) never released its link on the module-level send chain, so every
later send — in any conversation — parked on it forever. The composer
queued messages with no error and no recovery short of a page reload, and
steer, which bypasses the queue gate, was silently swallowed too.
- Key the POST-ordering chain per conversation. Ordering only means
anything within a conversation, so one stalled send no longer delays
every other session in the tab.
- Bound the wait on the prior send. Past it the successor proceeds and
only ordering degrades, which beats a chain that can deadlock.
- Surface a send that fails alongside a streaming turn instead of rolling
its bubble back in silence, without touching that turn's lifecycle.
- Let the active conversation's queue drain off the server's own status
once a stranded latch outlives any plausible POST, the way
flushBackgroundQueues already does for every other conversation.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): pin that a stalled send can't wedge another session
The E2E UI gate requires a Playwright test for web/** behavior changes. A
send whose POST never settles held the tab-wide POST-ordering chain, so
every later send in every conversation parked on it. This drives that
shape through the real UI: B's POST is held open, the user switches to A
via the sidebar (client-side nav, so the store survives), and A's send
must still reach the server.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Both floors were unsatisfiable by the CLI they gate, so
`harness_cli_installed` returned False for every shipping build. That makes
`harness_is_configured` false, and the host then refuses the launch frame
outright — kimi-native and hermes-native could not start a session on any
machine, reporting "not configured" however current the CLI was.
kimi: the harness drives Moonshot's `kimi-code` CLI — the `kimi` binary this
spec's own installer puts on PATH — whose releases are a 0.x series. The floor
was taken from the separately numbered `kimi-cli` project (1.x), so no
`kimi-code` build could ever satisfy `>=1.47.0`. Retarget it at the first
`kimi-code` release after the 2026-06-01 cutoff the sibling floors use: 0.7.0.
hermes: the floor assumed date-tagged releases, but Hermes reports a semver
version with the build date beside it (`Hermes Agent v0.19.1 (2026.7.30)`), so
the parser reads `0.19.1` — never `>=2026.06.05`. Use the functional
requirement the comment already documents: 0.17.0, where the parent_session_id
schema landed.
Adds a regression test per harness pinned to the CLIs' real `--version` output.
Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
Forwarding a message to the runner never checked the HTTP status. httpx
only raises on transport errors, so a runner that answered with a 4xx/5xx
read as a started turn: the server published input.consumed — telling the
client the runner had the message — and the session settled idle, showing
a finished turn for work that never ran.
A rejection now publishes failed carrying the runner's own error/detail,
persisted as labels so the reason survives a reload instead of vanishing
with the SSE edge. The labels are written before the status edge is
published so a client that reloads on failed can't race a snapshot that
has no last_task_error yet.
The transport-failure path keeps publishing idle: the runner never
answered, so the turn may yet run. A rejection means the live runner
answered and took nothing, which is what makes idle wrong there. Neither
is strictly terminal — the user item stays persisted either way, so a
later reconnect can still replay it as a recovery turn; failed is the
honest state for the runner we have now, not a promise the message is
gone.
The status is checked directly rather than through raise_for_status so the
runner-client fakes that expose only status_code keep behaving as they do
in production.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(web): insert dictated text at the caret, not the end of the draft
Voice dictation always appended to the bottom of the composer. A common
flow is to paste a block of context, click above it, and dictate the
instructions that should lead: those words landed under the pasted block
instead, and had to be cut and re-pasted by hand.
`useDictationInsert` built every update as `base + text`, so the caret was
never consulted. It now splices at the caret, padding with single spaces so
dictated words never fuse with the draft on either side (and skipping the
space before punctuation that hugs the previous word), then leaves the
caret after the inserted text so typing continues naturally.
The caret is read from the textarea at insert time rather than mirrored in
React state. The `select` event only fires for real range selections, so a
plain click that collapses the caret never reports one; `selectionStart` is
preserved on the element across blur, which also survives the mic button
taking focus. The composers only report that the field has been focused,
since an untouched draft's `selectionStart` of 0 is indistinguishable from
a caret placed at the start; until then text still appends, preserving the
previous behavior for restored drafts.
Consecutive utterances chain after the previous one rather than re-reading
the caret. A partial and its final can arrive in one React batch, where the
caret write (a layout effect) has not run yet and every insert would read
the same stale offset and interleave backwards.
The hook now takes the draft as a value instead of reading it inside a
setDraft updater. Transcripts arrive off a socket, where React defers the
updater, so any offset it computed would be written back too late for the
next partial and a streaming region would append instead of revise.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): track dictation ownership instead of inferring it from the draft
Addresses two defects found in review, both reproduced with a failing test
before fixing.
Requesting a caret on a no-op update stranded the request. The mic ends every
take with onInterim(""), which lands as an empty insert once the preceding
final has cleared the interim region. That produced a same-value setDraft,
which React can bail out of without committing, so the layout effect never ran
to clear the pending caret. Every later utterance then read the DOM caret as
stale and pinned itself to the tail, ignoring wherever the user had clicked:
the exact behavior this change set out to add. An insert that changes nothing
now returns before touching the caret bookkeeping.
Ownership was inferred by comparing the draft to the last string written, but
equality is not identity. Editing away and undoing back restores equality while
those characters now belong to the user, so a spent interim span could be
sliced back out of the middle of their text, breaking the invariant that
dictation never deletes text it didn't write. Ownership is now released as soon
as a draft arrives that this hook didn't write, and regained only by writing
again.
Also fixes spacing around delimiters: dictating just inside an opening bracket
left a stray space (`call( the arg)`), and quotes were treated as always
closing, so inserting before one fused the words (`say please"quoted"`). Quotes
are ambiguous enough that spacing them like any other character is the safer
default. The caret write now also restores scrollTop/scrollLeft when the
textarea is unfocused, since setting a selection there can scroll the element
to reveal it.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* test(e2e_ui): cover dictation landing at the caret
The e2e_ui judge asks for Playwright coverage of user-visible web changes, and
caret-positioned dictation had only unit tests.
Extends the existing dictation e2e (same fake mic device and fake ASR engine)
with the reported flow: paste a block of context, click above it, dictate, and
assert the words lead the pasted block instead of trailing it. A second take
with the caret moved back to the top covers the caret being honored again
rather than the text chaining onto the previous utterance.
Verified the test bites: against the pre-fix append-to-end behavior it fails
with the transcript at the end of the draft.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): settle dictation ownership when an insert changes nothing
A final utterance whose spliced result is byte-identical to the partial already
on screen deleted the dictated word. The server routinely finalizes exactly what
it last streamed, so the splice is a no-op, and the early return that skips the
caret request was skipping the ownership update with it. The interim region
stayed pending, so the end-of-take clear lifted the finalized text back out:
"hello PASTED" became "PASTED", losing the word entirely.
The no-op path now settles ownership before returning (a final still pins, an
empty clear still releases) while continuing to skip the caret request, which
is the part that must not run: a same-value setDraft can bail out without
committing, leaving the request outstanding and pinning later inserts to the
tail.
Also documents that focusedRef is deliberately never reset on blur. Clicking the
mic blurs the composer, and the caret the user left there is still the one they
can see and mean.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* chore: trigger UI preview build
The ui-preview workflow's label-gated jobs skipped on every "labeled" event
for this PR even though the label is applied and every documented gate passes
(not draft, MEMBER author, workflow active). Pushing an empty commit to fire a
"synchronize" event instead, whose payload carries the current label set.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(policies): add tag push protection to GitHub policy
Add a `deny_tag_push` parameter (default `True`) to the GitHub
policy that blocks pushing tags to remotes via `git push --tags`,
`git push --follow-tags`, or explicit `refs/tags/` refspecs. Tags
are immutable references that downstream CI/CD and release tooling
depend on; an agent pushing a tag can trigger releases, deployments,
or break semver expectations.
Tag refspecs (`refs/tags/v1.0`) are also filtered out of the branch
set so they don't pollute `write_branches` checks.
The check fires before repo/branch gating so even a tag push to an
undeterminable remote alias is denied rather than surfaced as ASK.
Set `deny_tag_push=False` to let tag pushes through normal write
gating.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* style(policies): join tag-push deny message onto one line for ruff format
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(host): run session runners in the workspace, not the daemon's cwd
A host daemon started from a directory that later disappears (a temp
checkout, a removed worktree) passes that dead cwd to every runner it spawns.
Path.cwd() then raises FileNotFoundError inside the runner and native
sessions fail with "Native Pi terminal failed to start" — hit live while
verifying the pi-native gateway fix.
Spawn the runner with cwd=<session workspace>, which _build_runner_env
already documents as the runner's cwd and which is verified to exist just
above the spawn.
Signed-off-by: David O'Keeffe <david.okeeffe@databricks.com>
* chore: retrigger CI (flaky integration test)
Signed-off-by: David O'Keeffe <david.okeeffe@databricks.com>
* fix(host): require an explicit runner workspace on the zygote fork path
fork_runner defaulted workspace to os.getcwd() — the daemon's cwd, the
exact value the workspace fix exists to avoid. The forked child was
already strict (it raises when the request carries no cwd), so the
manager was the only lenient link: a call site that omitted the argument
silently resurrected the deleted-cwd crash instead of failing loudly.
Make the parameter required so both ends agree, and cover the zygote
fork path's cwd, which had no test — only the direct Popen path did.
---------
Signed-off-by: David O'Keeffe <david.okeeffe@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
_inline_family_pi_provider returned on the first family carrying a base URL
and credential, never consulting the model. A gateway exposing both an
Anthropic and an OpenAI surface therefore served every model over
anthropic-messages, and a proxy that is not protocol-translating rejects
that — the turn hangs with no reply.
Order the families by the selected model instead: Claude ids prefer the
Anthropic family, everything else leads with OpenAI. The loop still falls
through to the other family, so a single-family translating proxy (LiteLLM
/anthropic passthrough serving GPT ids, or an OpenAI-compatible proxy
serving Claude) keeps working.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(web): preserve chat and browser widths when toggling the sidebar
The center chat column could be squeezed below a usable width when the
left sidebar opened: the right rail's resize clamp only accounted for the
viewport (0.6 * innerWidth), ignoring the sidebar, so an open sidebar ate
into the chat instead of the rail.
Make the rail's ceiling sidebar-aware. The clamp now reserves the open
sidebar's live width plus the chat's 480px minimum and the 8px gap, with a
99vw nominal cap. The reserve is applied only at render time — the stored
preferred width is untouched — so opening the sidebar temporarily shrinks
the rail and collapsing it restores the user's chosen width. A manual drag
still writes a new preference; viewport/sidebar changes recompute against it.
Also tighten the drag lifecycle while here: the window mousemove/mouseup
listeners now mount only during an active drag (state-driven, no idle
handler), and moves are coalesced through a single requestAnimationFrame so
a burst of events yields at most one width update per frame.
Tests: unit coverage for the sidebar-aware clamp + preference restore in
useResizableInlinePanel.test.tsx, and a Playwright e2e that toggles the
sidebar and asserts the chat stays >= 480px while the rail springs back to
its prior width.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* fix(web): preserve chat and browser widths when toggling the sidebar
The center chat column could be squeezed below a usable width when the
left sidebar opened: the right rail's resize clamp only accounted for the
viewport (0.6 * innerWidth), ignoring the sidebar, so an open sidebar ate
into the chat instead of the rail.
Make the rail's ceiling sidebar-aware. The clamp now reserves the open
sidebar's live width plus the chat's 480px minimum and the 8px gap, with a
99vw nominal cap. The reserve is applied only at render time — the stored
preferred width is untouched — so opening the sidebar temporarily shrinks
the rail and collapsing it restores the user's chosen width. A manual drag
still writes a new preference; viewport/sidebar changes recompute against it.
Two subtleties the first cut missed, both surfacing when both sidebars are
open and the window is then shrunk:
- The chat's 480px floor now outranks the panel's own 240px comfort
minimum. Previously `Math.max(minPx, ...)` pushed the rail back up to 240
once the chat-preserving ceiling dropped below it, squeezing the chat under
480. The panel now yields below its own minimum (to 0 if need be) so the
chat keeps its floor.
- A plain window resize that left the stored (no-reserve) width unchanged
never re-rendered, so the render-time reserve clamp went stale. A viewport
tick now forces the recompute on every resize.
Also tightened the drag lifecycle: the window mousemove/mouseup listeners
mount only during an active drag (no idle handler), and moves are coalesced
through a single requestAnimationFrame.
Tests: unit coverage for the sidebar-aware clamp, the chat-floor-wins shrink,
and preference restore in useResizableInlinePanel.test.tsx; a Playwright e2e
that toggles the sidebar and one that shrinks the viewport with the sidebar
open — both assert the chat stays >= 480px.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
* test(e2e-ui): regenerate visual baselines
---------
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
Clicking a file in the viewer was slower than the payload warranted: the
workspace-file reads inline the whole file in a JSON `content` field, and no
gzip applied to them — GZipMiddleware was mounted only on the static web-ui
mount — so each click paid a full uncompressed file transfer.
Measured A/B against two deployments (one on main, one on this change), 8 reps
per fixture, interleaved: a 1 MB TypeScript file under the line cap goes
1,050,566 -> 14,827 bytes on the wire (70.9x) and 2256 ms -> 1270 ms; a
2000-line slice of a larger file 122,187 -> 587 bytes (208x) and 1582 ms ->
1080 ms. Level 4 reaches the same ratio as 9 on source text and JSON for about
half the CPU.
Implemented as an APIRoute subclass on a dedicated router holding just the
three read endpoints, so the route table stays the source of truth for what
compresses. A path-matching middleware would have to re-derive that from the
request path, duplicating the router's matching — and because a path says
nothing about the method, it would also wrap the PUT/PATCH/DELETE handlers
that share these URLs. Starlette rejects a mismatched method before it reaches
the route's app, so a route class only ever sees the methods its route
declares.
Binary reads opt out of compression, because base64 of already-compressed
media gains ~1.3x for real event-loop time (385 ms at the 10 MiB binary cap).
The handler makes that call via `skip_gzip(request)`, which sets a flag on
`request.state`; the route class reads it back at send time. Deciding in the
handler keeps domain knowledge where the payload already is — the response is
`application/json` for every file, so the transport layer cannot tell binary
from text without re-parsing the body, and doing so brought its own failure
modes (a length-bounded prefix scan, and a dependency on field ordering).
Response body, headers, status, and OpenAPI are unaffected.
Also declines `Range` requests, since a 206's Content-Range describes the
unencoded representation, and negotiates `Accept-Encoding` properly: tokens
are case-insensitive and `q=0` means the client declined (RFC 9110 §12.5.3),
which a substring test would miss.
Small files are unchanged: a ~1040 ms fixed per-request cost dominates them,
and that is untouched here.
Test Plan:
- tests/server/routes/test_session_resources.py: 14 new cases driving the real
routes through the real router — text read gzipped and byte-intact, binary
read skipped, a deeply nested binary path still skipped, text whose content
contains `"encoding":"base64"` still gzipped, directory listing and diff
gzipped, identity honored, 10 parametrized Accept-Encoding negotiations,
PUT/PATCH/DELETE on the read paths left uncompressed, and siblings
(changes/search/shell) untouched
- 138 passed in that file; 168 across it plus the app, REST, and
hosts-filesystem integration suites
- full tests/server + tests/runner: 33 failed / 4905 passed, with a byte-
identical failure set at the parent commit (33 failed / 4884 passed), so no
regressions
- verified at raw ASGI on the real route: absent Accept-Encoding, gzip, GZIP,
gzip;q=0, and Range each behave correctly
- OpenAPI unchanged: the read paths still document all four methods, and the
internal diff route stays out of the schema
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(pi-native): route uncataloged models by family instead of the Anthropic surface
pi-native builds its primary Pi provider on the Databricks gateway's
Claude-only /ai-gateway/anthropic surface and splits non-Claude families
across the Responses, serving-endpoints and MLflow surfaces using the live
Unity Catalog model-services list. That split only holds while the fetch
succeeds — it is best-effort by design, so an expired token, a network blip
or a workspace that lists nothing all yield empty lists. to_models_config
then registered the selected model on the primary regardless, so a
non-Claude model went to the Anthropic surface and the gateway answered
"API type 'anthropic/v1/messages' is not supported by ...". The turn never
finished and the user saw no reply and no reason.
Keep the live catalog authoritative and fall back to classifying the model
by family when it did not list one. The classifier moves next to the other
Pi compatibility fallbacks and mirrors pi_executor's _pi_provider_for_model,
so both Pi paths route a given id to the same surface. A model whose surface
this credential cannot reach, or that Pi cannot parse on any wire, is left
unregistered so Pi fails fast rather than hanging — and that refusal is
surfaced to the session as an error banner via the path an unresolvable
credential already uses, since a log line the user never sees reads as
another silent hang.
Carrying the reachable surfaces on the config also distinguishes the
gateway's Claude-only primary from a LiteLLM-style proxy, which speaks
anthropic-messages for arbitrary models and must keep self-registering.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(pi-native): render the models config once per launch
The launch both writes models.json and reads it back to resolve --provider,
so rendering twice logged how an uncataloged model was routed twice. Thread
the rendered config through write_pi_models_config instead.
Also drop the overclaim that the surface classifier mirrors pi_executor's
_pi_provider_for_model: for a keyword model (GLM, kimi) carrying no wire
metadata the two disagree, because this follows the catalog builder's split
and sends those to Responses. Name the disagreement rather than imply
parity. Align the two membership checks on entry.get("id").
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(pi-native): keep databricks-* aliases off the Responses surface
Probing a live workspace showed the keyword surface split only holds for
system.ai.* ids: the gateway serves Responses passthrough for
system.ai.glm-5-2 but answers "Responses API passthrough is not supported
for model databricks-glm-5-2" for the alias of the same model. The
fallback classifier applied the keywords to both, so an uncataloged GLM,
kimi, or qwen3 alias was routed to a surface that 400s.
Restrict the keyword check to system.ai.* ids and let aliases fall to
chat completions, which the workspace accepts for all of them.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
`omni host status` printed server URLs and daemon log paths as bare text,
so terminals had to guess where each link started and ended. On a narrow
terminal the URL was middle-truncated for display with no separate click
target, and the log path had no width budget at all so it wrapped
mid-path — leaving the terminal to detect a "URL" spanning several lines
of the status block.
Emit OSC 8 hyperlinks instead: the visible text stays shortened to fit,
while the click target carries the full, untruncated URL (or a file://
URI for the log) and exact bounds. Also budget the log line so no line
fills the terminal width.
Closes#3861
Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
* fix(server): honor OMNIGENT_LOCAL_SINGLE_USER on non-loopback binds
A non-loopback bind auto-enabled accounts mode without checking whether
the operator had already declared a single-user server. Accounts mode
resolves identity via the session cookie, so neither the reserved
"local" fallback nor the X-Forwarded-Email header is reachable — every
request 401s and the host tunnel 403s, taking every agent down rather
than prompting for login.
A truthy OMNIGENT_LOCAL_SINGLE_USER now keeps header mode and warns that
the server serves unauthenticated requests on an exposed interface. Only
truthy counts, so LOCAL_SINGLE_USER=0 remains an opt-out, and an explicit
AUTH_ENABLED=1 still wins.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* test(e2e): close mock-LLM race in the no-AGENT harness round-trip
Harnesses registering a background session-title generator (codex among
them) issue an extra model call that races the user turn for the same
keyed mock queue. The test queued a single marker for every harness but
claude-sdk, so whichever call landed first consumed it and the other got
the queue default "Mock LLM response" — the turn never rendered the
marker and pexpect EOFd.
Serve the marker as a non-resettable fallback so every call on the key
answers with it, making the assertion independent of call ordering and
count. Adds set_fallback_mock_llm, mirroring the e2e_ui conftest helper.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(cli): scope the single-user exposure warning to header mode
The non-loopback single-user warning fired whenever a truthy
OMNIGENT_LOCAL_SINGLE_USER met a non-loopback bind without an explicit
OMNIGENT_AUTH_ENABLED, without asking which auth source actually
resolved. An explicit OMNIGENT_AUTH_PROVIDER=accounts (or oidc) beside
the marker wins outright in resolve_auth_source(), so identity goes
through the cookie path and login really is required — yet the warning
still told the operator the server would serve unauthenticated requests
as the "local" user.
Gate on resolve_auth_source() == "header" instead. That is the only mode
where the "local" fallback is reachable, so it is the only mode with
something to warn about. It also fixes the mirror case the old
condition suppressed: AUTH_ENABLED=0 is "set" but falsy, resolving to
header mode, so that exposure is real and now gets announced.
Reported by the automated review on #4224.
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* feat(server): warn about exposed single-user mode on container startup
The unauthenticated-single-user warning only existed in the CLI bind
path, where it prints to stderr. Operators who set the marker through a
systemd unit or container env never see that -- stderr is buried in a
platform log viewer.
Worse, the container paths never ran the CLI helper at all. The Docker
entrypoint sets OMNIGENT_LOCAL_SINGLE_USER=1 for its documented
AUTH_ENABLED=0 kill-switch posture and binds 0.0.0.0, which resolves to
header mode with the "local" fallback live -- so a container started
with OMNIGENT_AUTH_ENABLED=0 served unauthenticated requests as "local"
with no warning whatsoever.
Move the gating into warn_if_single_user_exposed() in the auth module,
which owns the policy, and have each path choose how to surface it:
Click stderr for the CLI, logger.warning for the Docker and Databricks
entrypoints. Adds bind_host_is_loopback(), replacing the CLI's inline
literal tuple, so any 127.0.0.0/8 address counts and an unresolvable
host errs toward "reachable" -- over-warning is the safe direction for a
security notice.
Behavior for the CLI is unchanged (its 31 cases still pass); the
container paths gain the warning they never had.
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Policy evaluation sits on the PreToolUse critical path — the hook blocks on
the verdict — and spent most of its time re-reading the same rows.
build_policy_engine fetched the conversation about four times (root
resolution, labels, session state, model override) and walked the spawn tree
twice, because the session-wide gating seed and the per-node subtree seed
each called load_session_usage, which does its own conversation read plus a
full paged tree scan.
One conversation read and one tree scan now feed everything. Both usage seeds
derive from that list through a pure aggregation, so they stay semantically
distinct: cost gating remains tree-wide, so a sub-agent gates against the
whole session's spend, while the subtree total remains the per-node display
figure. A caller that already holds the row can pass it and skip the read.
A row the caller supplies is a HINT, not a fact. It names a tree, and loading
that tree verifies the claim: if the conversation is not in it, the root is
resolved again. Everything downstream — the rows, the root id, the policies
attached to that root, the accounting sums — comes from the tree that
verification produced. Deriving the root from the caller's row while taking
rows from a corrected tree mixes two epochs, and a conversation deleted and
recreated under a different root then seeded the old tree's spend.
Mutable state is likewise re-derived rather than trusted: labels, session
state, model override and agent binding all come from the verified tree,
whoever read the row first, because a caller's preload and this function's own
read are equally stale by the time a decision is made. A row absent from the
tree is confirmed with one re-read and then fails closed. A tree that needed
more than one page cannot vouch for its own rows — page one was read before
page two — so identity is confirmed once in that case, which single-page trees
never pay for.
Also here, because it is the same tree: the ancestor cost re-publish used to
do a conversation read plus a full tree scan PER ancestor, and derived the
chain from a row read earlier in the request. It now walks the verified tree,
so the whole fan-out costs one load and cannot publish to a chain that has
since changed. A chain that cannot be walked to the root yields nothing
rather than a prefix, since the caller publishes to every id returned.
The tree also stopped excluding archived conversations. Archiving is a listing
concern; the tree is an accounting structure. Excluding them let an archived
root — or an archived mid-tree node, which orphaned its descendants from the
walk — seed the enforcement total as $0 and allow a tool call over budget.
Archived spend consequently appears in displayed totals too, which is the
intended reading: the badge should agree with the gate.
Measured on both dialects: 30 queries per build to 6, or 3 when the caller
supplies the row. The whole authenticated route, by (tree size, whether the
caller supplies the row): 11 on a one-page tree when supplied, 14 when not;
17 on a 101-node tree when supplied, 20 when not. The tree load pages, so
cost is not independent of tree size, and the extra 3 on a paged tree over
the one-page count are the paging confirmation above, a full conversation
read — consistent at both tree sizes and both supplied/not-supplied. Counted
as SQL statements rather than store calls, because a store-call count cannot
see a helper that issues three statements per call. The route-level oracle
below covers only the one-page shape; the 101-node figures are measured, not
pinned by a test yet.
Every oracle here is paired with the mutation that kills it, including the two
that pin this round's fixes: deriving the root from the pre-refresh row fails
the recreated-child test, and skipping the paged-tree confirmation fails the
switch-during-paging test.
Signed-off-by: Andrew Reid <andrew@reid.ee>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Andrew Reid <andrew@reid.ee>
flush() and close() queue a marker carrying a Future and then await it, but
only the delta worker resolves those futures, from inside its loop. At
asyncio.run teardown the worker and the caller are cancelled in one pass, so
the marker is queued with nobody left to complete it and close() parks
forever. The runner never exits, which is also why the clean exit the
idle-resume work assumes is not always reached.
Race each marker against the worker itself, bounded, since a worker that has
stopped will never resolve it and the cancellation order between the worker
and its caller is arbitrary. Only reap a worker that actually finished;
awaiting a wedged one reintroduced the unbounded wait. Guard the two
resolvers so a marker settled elsewhere cannot kill the worker with
InvalidStateError, which _ensure_worker would never restart.
Closes#2748
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
* examples: fix the commented web_search snippet in deep-research
The Google Programmable Search snippet in examples/deep-research/config.yaml
was missing search_provider, which _search() requires and has no default for,
so uncommenting the block verbatim returns "web_search error: no
search_provider configured" instead of searching. The Perplexity and Nimble
snippets below it already name theirs.
Also drop the hardcoded "bundled catalog default is claude-opus-4-8" claim:
the default is resolved at runtime by default_chat_model() from the configured
provider's catalog (newest model of the preferred tier), so naming one model
goes stale as the catalog moves.
Comments only, no behaviour change.
Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
* examples: drop the undocumented search mode from the deep-research skill
The skill told the model to pass `realtime` to `search_web_pages` when latency
matters, but `realtime` is not part of Keenable's documented public tool
surface: `mode: pro` is the documented default. Leaving the hint in means the
agent can send a mode that is not covered by the public API contract.
Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
---------
Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
* fix(opencode-native): re-seed dedupe on every SSE reconnect to close gap (#1778)
The opencode-native forwarder only called seed_dedupe_from_history() once
at startup. After an SSE reconnect the dedupe set was not refreshed, so
content produced during the disconnect window was never delivered (the
live stream re-emitted it as duplicate events that the stale dedupe set
silently dropped).
Fix: move seed_dedupe_from_history() inside the reconnect loop so it is
called on every attempt (initial connect and each reconnect). The
existing deduplication in OpenCodeForwarderState.mark() is idempotent:
keys seen before the drop are re-marked on reconnect and will not be
re-posted; new keys introduced during the gap are not yet in the set, so
those events are forwarded exactly once.
Also removed the dead update_last_event_id() call from handle_event.
The SSE Last-Event-ID resume header was never honoured by opencode's
server, so this call was dead code that imported an unused symbol and
created a misleading bridge write on every event.
Tests added in tests/test_opencode_forwarder_reconnect.py:
- seed_dedupe_from_history is called on initial connect
- seed is called on every reconnect attempt (not just the first)
- content seeded before a reconnect is not re-posted after reconnect
- update_last_event_id is no longer present in the module
* fix(opencode-native): replay history on SSE reconnect
* fix(opencode-native): add missing Any import and narrow info type in catch_up_from_history
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): fork fresh from project default base branch instead of reusing last worktree
When a project configures a default base branch (Project settings), a fresh
new-chat should fork a new branch off that default — not silently continue in
the user's last-used worktree.
The composer auto-seeds the working directory from the most-recent workspace.
When that path is an existing linked worktree, the branch field prefilled from
it, which flipped shouldCreateWorktree to false and made the base-branch
seeding effect early-return — so the project's default base branch was never
applied. This was a gap in the new default-base-branch feature, not a
regression of prior behavior (the last-used-worktree landing predates it).
Now, when a project default base branch is set, the once-per-host auto-seed
probes the recent path's repo; if it's a linked worktree, it redirects the seed
to the repo's main work tree and auto-generates a worktree-<uuid> branch so the
new-worktree flow (and base-branch fill) engages. Deliberate picks, sandboxes,
non-git paths, and projects with no default are unaffected.
The fork-fresh decision is resolved to a stable memoized value so the seed
effect depends on the decision, not the churning worktree-list array identity —
avoiding an intermediate re-fire that would let the auto-seed win the race
against the project-config workspace prefill.
Adds unit coverage (both the redirect and the no-default passthrough) and an
e2e_ui case asserting the create forks off the default at the main repo.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): gate fork-fresh branch generation on actual seed + empty branch
Address review findings on the fork-fresh seed effect:
- B1: generateBranchName() and the worktreeSeededForRef write fired on
didForkFresh alone, even when setWorkspace was a no-op because the field
already held a config-supplied workspace. A project that sets both a
workspace and a default base branch (with a linked-worktree recent path)
would be turned into an unexpected worktree fork. Gate the fork-fresh
side-effects on the workspace actually being seeded (cur === "").
- B2: no empty-branch guard meant a branch typed/picked during the probe's
async load window got clobbered when the probe resolved. Add the same
branchName === "" && prefilledBranch === "" guard the sibling
opt-in-worktree effect enforces.
- Store worktreeSeededForRef in the raw (un-normalized) representation the
opt-in-worktree effect compares against (workspaceTrimmed), so a
trailing-slash difference can't let it fire a second branch generation.
Adds a unit test for the B1 config-workspace passthrough (plain launch, no
fork).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): fall back to seeding the candidate when the worktree probe errors
Address the blocking review finding: the fork-fresh seed was gated on the
forkFreshMainPath memo, which returned undefined whenever the worktree probe's
data was undefined. useHostWorktrees maps a 400 (non-git path) to [], but any
other non-OK response throws — leaving React Query's data undefined for good.
That left forkFreshMainPath stuck at undefined, the seed effect early-returning
forever, and the working directory unseeded indefinitely for default-base-branch
projects on a transient 5xx (previously the seed was unconditional).
Treat a probe error (isError) as "no redirect" (null) so the seed still lands
on the candidate as-is, mirroring the hook's deliberate 400 → [] tolerance.
Adds a unit test asserting the recent workspace is still seeded when the probe
errors (verified it fails on the pre-fix code — the chip stays blank).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
## Related issue
Closes OMNI-2524 — https://linear.app/omnigent/issue/OMNI-2524
## Summary
- In local mode `omnigent host --background` (#4317) already starts the local
server *and* registers this machine as a host, so it is effectively the "turn
Omnigent on" command — but finding it means knowing the `host` concept and a
flag. `omnigent start` is that command under the name people look for, and is
symmetric with the existing `omnigent stop`.
- It is a full alias, not a second implementation: same `--server` /
`--non-interactive` options, the same CLI → config → local target resolution
(`_resolve_host_server`), delegating to the same `_run_background_host()`.
`host --background` keeps working for scripts that want the host lifecycle by
name (`host status` / `host stop`).
Registered in `_CLICK_SUBCOMMANDS` too: `main()` consults that allowlist
before handing argv to click, so a top-level command missing from it can be
misread as the removed ad-hoc chat (enforced by
`test_click_subcommands_allowlist_covers_registered_commands`).
- The stop hint each entry point echoes is now passed in, so `start` suggests
`omnigent stop` while `host --background` keeps mirroring its own invocation.
```
$ omnigent start
Started the host daemon in the background (pid 52359).
server: http://127.0.0.1:6767
log: ~/.omnigent/logs/host/host-20260806-212352-515540.log
Stop it with:
omnigent stop
```
## Test Plan
- `uv run --extra dev pytest tests/host/test_cli_host.py -q` → 23 passed.
- Manually: `omnigent start` printed the block above in ~4s; `omnigent host
status` showed `mode=local process=online host=online`; a second `omnigent
start` reported `already running (pid 52359)` with no second spawn; and
`omnigent stop` reported `Stopped 1 daemon(s) and the background server`,
after which `host status` and `server status` were both clear.
- `omnigent --help` lists `start` next to `stop`; `omnigent start --help`
documents the alias and both options.
## Demo
N/A — CLI-only change; the new output is quoted above.
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Two new tests in `tests/host/test_cli_host.py` cover `start` spawning the same
detached local-mode daemon (with the local server URL reported, the foreground
loop skipped, and `omnigent stop` — not `host stop` — suggested), and
`start --server <url> --non-interactive` passing the target through to both the
sign-in pre-flight and the daemon argv. The daemon spawn and local-server
discovery are stubbed, so no process or log file is created; the detached
daemon itself was covered by the manual run above.
## Changelog
`omnigent start` starts the local server and registers this machine as a host —
the on switch to go with `omnigent stop`.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* revert(sessions): remove delegated approval authority (#3446)
Reverts the delegated approval feature from #3446, returning to
owner-only approval (the deny-by-default behavior from #3416). Owners
can no longer delegate a "can_approve" capability to shared editors;
approvals are again restricted to the session owner, while editors keep
reject/cancel.
The change is a faithful inverse of #3446 rebased on current main:
files untouched since #3446 revert byte-identical to their pre-feature
state; files later commits also modified keep those newer changes and
drop only the approval lines.
Migration handled non-destructively for deployed databases:
- The original additive migration (c4d5e6f7a8b9) is kept intact so
already-migrated databases still resolve their history.
- A new forward migration (f7a8b9c0d1e2) drops the session_permissions
.can_approve column; its downgrade re-adds it.
Also removes a dangling import of _approval_access_from_grants in
sessions/__init__.py left by the later wildcard-import refactor (#3934),
which otherwise broke server import after the helper was reverted.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* revert(sessions): remove shared-message attribution (#3422)
Reverts the model-visible shared-message authorship feature from #3422.
Messages no longer gain `[author]:` prefixes in the model prompt, the
SHARED_SESSION_AUTHORSHIP_INSTRUCTION framework instruction is removed,
and the OMNIGENT_SHARED_MESSAGE_ATTRIBUTION_ENABLED switch is gone.
Persisted `created_by` authorship (a store-level column predating #3422)
is unaffected.
Rebased on current main, keeping later independent work in the same
regions:
- Smart Routing's conditional `model_override` on the native-terminal
forward path is preserved.
- The `host_store` parameter added to the event-forward path is kept.
- The two `test_external_interrupt_*` tests from #4160 (which overlap
#3422's added block in test_sessions_endpoints.py) are kept; only
#3422's `test_external_user_message_strips_model_author_prefix` is
removed.
Also removes dangling imports of `_strip_pending_author_prefix` in
orchestration.py and sessions/__init__.py left after the helper's
definition was reverted.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* revert(sessions): restore editor approval authority (#3416)
Reverts the owner-only approval restriction from #3416. Approval events
and URL-based elicitation resolution are gated at LEVEL_EDIT again, so
shared editors — not only the owner — can resolve approvals.
SECURITY REGRESSION (intentional, per request): #3416 was a security
fix. Shared-session tools execute with the session owner's runner
identity and ambient credentials, so a shared editor can once more
authorize owner-credentialed tool calls. This, together with the #3422
and #3446 reverts, fully unwinds the #2150 stack and re-opens #2150.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
## Related issue
N/A
## Summary
- Bumps the iOS app's marketing version (`CFBundleShortVersionString`) from
`0.1.0` to `0.1.1` ahead of cutting a TestFlight build, so the release is not
published under the same user-facing version as the previous one.
- Only the **Omnigent** app target's Debug and Release configurations change, as
`web/ios/RELEASE.md` prescribes. The `.tests` / `.uitests` bundle versions are
left at `0.1.0`; they are never shipped, and Android's equivalent bump (#4309)
likewise touched only the app's version.
- The build number is deliberately untouched: it is computed per upload as
`latest_testflight_build_number + 1` and injected by fastlane at archive time,
so it must not be bumped by hand.
## Test Plan
- `xcodebuild build -project Omnigent.xcodeproj -scheme Omnigent -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5'`
succeeds, and the built app's `Info.plist` reports the new version:
`plutil -extract CFBundleShortVersionString raw .../Omnigent.app/Info.plist` → `0.1.1`.
- `plutil -lint web/ios/Omnigent.xcodeproj/project.pbxproj` passes, confirming the
hand-edited project file is still well-formed.
- Verified the two changed entries belong to the `ai.omnigent.ios` target (Debug
and Release) and that no other target's version moved.
## Demo
N/A — no user-visible interface change; only the reported version string.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
A version string has no behaviour to unit test. Verified by building the app and
reading `CFBundleShortVersionString` back out of the built `Info.plist`, plus a
`plutil -lint` on the edited project file to catch a malformed hand edit. The
existing iOS suites continue to cover app behaviour.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A
## Summary
- Someone opening Omnigent on a managed device has to know and type their
organization's server URL. This lets an administrator preset that list, so the
connect screen offers the org's servers under a "Provided by your organization"
heading and in the server switcher.
- Preset servers are **offered, not enforced**: nothing connects automatically,
the user can still type any URL, and preset entries are never written to the
saved-server list — so withdrawing the configuration withdraws them from the
app, and they never consume the 5-entry recents cap and evict a server the user
chose. `SettingsStore` is untouched, which makes that a structural guarantee
rather than a rule to remember.
- Two delivery channels, one decoder: a `com.apple.configuration.app.managed`
declaration read via the `ManagedApp` framework (preferred — validation errors
are reported back to the admin console and the device event log), and the
classic `com.apple.configuration.managed` defaults key (works on any MDM, no
error reporting). Declarative wins when both are present.
- Validation lives in `init(from:)` so a bad value becomes actionable admin
feedback instead of a server that silently never appears. Four documented error
codes; `https` only, because release builds keep App Transport Security
defaults and an `http://` preset could not load anyway.
- `web/ios/docs/managed-app-configuration.md` is the published specification
(keys, error codes, sample payload) — Apple's guidance is to host this where
administrators can reach it, so it is a standalone doc.
- Raises `IPHONEOS_DEPLOYMENT_TARGET` to 26.0, which the `ManagedApp` framework
(iOS 18.4+) no longer needs to be gated behind.
```
declaration (com.apple.configuration.app.managed / AppConfig) ─┐
├─► OmnigentManagedConfiguration
defaults key (com.apple.configuration.managed) ────────────────┘ (validate, https, dedupe, cap 10)
│
ManagedServers.resolve(declarative:legacy:)│ declarative wins
▼
ConnectView "Provided by your organization" + ServerSwitcher
(merged at read time; never persisted)
```
Two incidental fixes the change forced:
- `ConnectView`'s server rows only hit-tested the URL's glyphs, so a tap on the
empty part of the pill did nothing. This was pre-existing on the recents rows;
found by the new UI test, fixed with `.contentShape`.
- The iOS 26 floor surfaced a deprecation warning for
`NSURLErrorFailingURLStringErrorKey`; the redundant fallback was removed (the
caller already falls back to the web view's own URL).
## Test Plan
`xcodebuild test -project Omnigent.xcodeproj -scheme Omnigent -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5'`
- 65 unit tests pass (+7 for the classic channel and precedence). The decoder is
covered by decoding property lists directly — the exact shape the framework
hands `init(from:)` — so no device management is involved: absent key, empty
list, blank entry, invalid URL, `http://`, non-web scheme, over the cap, a bare
string instead of a list, duplicate origins, order preservation, and that our
error codes stay out of the system-reserved range.
- `ManagedServersUITests` drives the whole flow in the simulator through a
DEBUG-only `--omnigent-managed-servers` launch argument: preset servers appear
under their own heading, the app does not auto-connect, and tapping a row loads
it.
- Verified the classic channel end-to-end on a simulator with no launch argument,
pushing the same key an MDM writes:
`xcrun simctl spawn booted defaults write ai.omnigent.ios com.apple.configuration.managed '{ serverUrls = ("https://omnigent.corp.example.com", "https://my-workspace.cloud.databricks.com/ml/omnigents"); }'`
- Verified a mid-session configuration change: rewriting the key and returning to
the app replaces the list. This caught a real bug —
`UserDefaults.didChangeNotification` does not fire for an out-of-process write,
which is exactly how a configuration arrives, so the re-read is anchored to
`didBecomeActive` (plus a `synchronize()` to drop the stale in-process cache).
- `RedirectConsentUITests` and the deep-link UI tests still pass.
`OmnigentUITests.testLocalServerSnapshot` fails, but identically on a stashed
clean tree — it needs a live dev server.
- `pre-commit run` clean on all changed files.
Not covered: delivery of a real declaration, and the error codes reaching an
admin console. Nothing can deliver a declaration to a simulator, so that needs a
device enrolled in an MDM with declarative app configuration support.
## Demo
Preset servers on the connect screen, delivered through the classic channel with
no launch argument (`defaults write` of `com.apple.configuration.managed`), and
after an administrator changed the configuration mid-session:
| Two servers preset | Administrator changed it, user returned |
| --- | --- |
|  |  |
## Type of change
- [ ] Bug fix
- [ ] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [x] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Manual verification covered what automation cannot reach. The DEBUG launch
argument the UI test uses bypasses configuration delivery, so both channels were
exercised by hand on a simulator: the classic key was pushed with `defaults
write` (the same key an MDM writes, hitting the real decoder, validation, merge
and UI), then rewritten mid-session to confirm the app picks up an administrator's
change. Declarative delivery and admin-facing error reporting remain unverified —
they require an enrolled device, and no simulator can receive a declaration.
## Changelog
Administrators can preset the iOS app's server URLs with a managed app configuration, so managed users pick their organization's server instead of typing it
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(policies): thread resolved sandbox spec into claude-native bridge tools
force_sandbox/enforce_sandbox correctly resolves a policy-forced sandbox
onto a session's os_env.sandbox (runner/app.py's
_apply_sandbox_override_from_verdict), and that decision reaches the
claude-native terminal process itself. It never reached the bridge's own
sys_os_shell/sys_os_read/sys_os_write/sys_os_edit tools, though: those are
registered with the Claude Code subprocess via --mcp-config and backed by
an OSEnvironment that claude_native_bridge.py's _build_tools() built with
a hardcoded OSEnvSandboxSpec(type="none"), because prepare_bridge_dir()
never wrote a sandbox field into the bridge's on-disk config in the first
place. A server operator configuring force_sandbox for claude-native
sessions got silent, unenforced host access from the agent's own tool
calls despite the policy evaluating successfully.
prepare_bridge_dir() now accepts the resolved sandbox spec and persists
it; _build_tools() reads it back and falls through to the prior
unsandboxed default when absent, so paths with nothing to carry (e.g. the
omnigent claude CLI's own synthesized wrapper spec) are unaffected. The
orchestration.py call site threads the same agent_os_env used for the
terminal process's own sandbox, so both surfaces agree.
Signed-off-by: Kaushik Kumaran <kumarankaushik@gmail.com>
* fix(policies): stop credential_proxy from corrupting the bridge sandbox round-trip
Polly's automated review on PR #3910 found a real bug in the fix: dataclasses.asdict
flattens OSEnvSandboxSpec.credential_proxy (a nested CredentialProxySpec) to a plain
dict, and OSEnvSandboxSpec(**payload) on read has no way to tell that dict apart from
a real one, so it gets assigned straight through. Any sandboxed code that later
dereferences .entries / .databricks on it crashes with AttributeError, exactly in the
configuration this PR exists to support (a real sandbox backend plus a credential
proxy). Verified this empirically before and after the fix.
credential_proxy is resolved parent-side only and was never meant to cross this kind
of boundary in the first place - SandboxPolicy.to_jsonable already excludes it for the
same reason, since it can carry a credential source (an env var name or a shell
command) that has no business landing in a file on disk. This drops it from the
bridge config the same way, rather than inventing a new serialization path, and adds
a test that proves it's dropped cleanly rather than corrupted.
Signed-off-by: Kaushik Kumaran <kumarankaushik@gmail.com>
* test(policies): make sandbox round-trip tests platform-independent
CI caught what my local macOS run couldn't: both new tests hardcoded
darwin_seatbelt, which only resolves on macOS, so they failed on Linux CI
runners with OSError: darwin_seatbelt sandbox is only available on macOS.
Patches create_os_environment at the boundary instead, the same pattern
tests/inner/test_codex_harness.py already uses for this exact class of
problem (test_executor_factory_decodes_os_env_json patches CodexExecutor.__init__
rather than resolving a real backend). Asserting on the captured OSEnvSpec
proves the config plumbing is correct without depending on which OS the
test happens to run on.
Signed-off-by: Kaushik Kumaran <kumarankaushik@gmail.com>
* fix(policies): satisfy pyrefly's dict invariance check on the sandbox payload
pre-commit's pyrefly hook failed in CI (never ran locally before, since pyrefly
wasn't actually installed in the local dev venv despite being in the dev extra):
dict[str, X] is invariant in its value type, so dataclasses.asdict()'s inferred
return type isn't assignable to a dict[str, object] annotation even though every
member of that union is an object. dict[str, Any] is the correct annotation here,
matching how Any bypasses variance checks for exactly this kind of "whatever
asdict() gives me" case.
Signed-off-by: Kaushik Kumaran <kumarankaushik@gmail.com>
---------
Signed-off-by: Kaushik Kumaran <kumarankaushik@gmail.com>
## Related issue
Closes OMNI-2516 — https://linear.app/omnigent/issue/OMNI-2516
## Summary
- `omnigent host` only ever ran in the foreground, so registering a machine as
a host cost a dedicated terminal — even though the detached daemon it needs
already exists and is what `run` / `claude` / `codex` spawn via
`_ensure_host_daemon()`. `--background` exposes that path directly: spawn (or
adopt) the daemon, report it, and return.
- Sign-in stays interactive. A detached daemon has no terminal to run the
browser login on, so `_ensure_databricks_server_auth()` runs in the
foreground *before* the spawn; otherwise the daemon dies in the background
with an opaque "redirected to a login page" error. `--non-interactive` still
fails with the `omnigent login` hint instead of prompting.
- In local mode the daemon also owns the local Omnigent server, so the command
waits for that server and reports its URL — otherwise the Web UI is
unreachable without a follow-up `omnigent server status`. That makes
`omnigent host --background` the whole "start everything" step, which is now
the README quickstart (it replaces the `server --background` + `host` pair).
- A daemon that dies on startup (bad URL, missing credentials) leaves nothing
on the terminal, so the command waits a 2s grace and surfaces the daemon log
rather than falsely reporting success.
Output is a colorized headline plus aligned detail rows, with the stop command
on its own line so it can be copied:
```
Started the host daemon in the background (pid 74241).
server: https://dbc-…/api/2.0/omnigent
log: ~/.omnigent/logs/host/host-20260806-205308-765542.log
Stop it with:
omnigent host stop --server https://dbc-…/api/2.0/omnigent
```
That stop command mirrors the invocation: `host` and `host stop` resolve their
target identically (the `--server` value, else config, else local), so the flag
is echoed only when the user named a target — a bare `host --background` prints
a bare `omnigent host stop`. Colorizing reuses the existing `NO_COLOR`-aware
helper, renamed `_help_style` → `_cli_style` now that it is not help-only.
## Test Plan
- `uv run --extra dev pytest tests/host/test_cli_host.py -q` → 21 passed.
- Manually, local mode: `omnigent host --background` reported
`server: http://127.0.0.1:6767` and a bare `omnigent host stop` (no
`--server` typed, none echoed), which then stopped it.
- Manually, remote mode: `omnigent host --background --server https://dbc-…`
printed the block quoted above; `omnigent host status` showed
`process=online host=online`; re-running reported `already running (pid …)`
with no second spawn; and the echoed `host stop --server …` stopped it.
## Demo
N/A — CLI-only change; the new output is quoted above.
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Four new tests in `tests/host/test_cli_host.py` cover the spawn output
(including the local server URL and a flagless stop hint), that the foreground
daemon loop and in-process local-server bring-up are skipped, reuse of a
healthy daemon via an explicit `--server ""` (whose stop hint keeps the flag),
and that sign-in runs before the spawn. The daemon spawn and local-server
discovery are stubbed, so no process or log file is created. Manual
verification covered both modes end to end; the exits-immediately grace path is
covered by tests only.
## Changelog
`omnigent host --background` starts the local server and registers this machine
as a host without tying up a terminal.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A — no tracking issue.
## Summary
- Managed users had to type a server URL by hand on first launch, with no way
for IT to hand it to them. The Android shell now publishes an [Android managed
configuration](https://developer.android.com/work/managed-configurations), so
any EMM (Intune, Jamf, Workspace ONE, Google Workspace, Android Management
API) can preconfigure the server URLs an org uses.
- One restriction key, `serverUrls`: a comma- or newline-separated list, most
preferred first. `ManagedConfig` parses it (defaults a missing scheme to
`https://`, drops unparseable entries, collapses same-origin duplicates, caps
at 8) and `ServerStore.offeredServers()` puts the presets ahead of the user's
recent servers in the one existing list — on the connect screen and in the
server switcher.
- Presets are offers, not policy enforcement: the app never auto-connects and
never skips the connect screen, the user can still type any other server, and
a preset is never written to prefs so an admin's later edit is picked up on the
next read.
Android offers no plain string-array restriction type, hence the delimited
string: `multi-select` needs the app's own schema to enumerate every possible
host (they are customer-specific), and `bundle_array` renders poorly or not at
all in several EMM consoles.
```
EMM console ──push──> RestrictionsManager ──> ManagedConfig.serverUrls
│
ServerStore.offeredServers() ──┤ presets first
│ then recents (origin-deduped)
ConnectActivity list ◀─────────┴─────▶ server switcher menu
```
## Test Plan
- `cd web/android && ./gradlew :app:testDebugUnitTest` — 50 tests, 49 pass. The
one failure, `MainActivityTest > configuration change updates system bar icon
polarity`, is pre-existing: verified failing identically at `HEAD` in a clean
worktree without these changes. Not touched here.
- `./gradlew :app:assembleDebug` — confirmed the `APP_RESTRICTIONS` meta-data
lands in the merged manifest and `res/xml/app_restrictions.xml` is packaged in
the APK.
- On a wiped API 35 emulator with Test DPC 9.0.12 as device owner: Test DPC →
Managed configurations → Omnigent → **Load manifest restrictions** renders our
schema and produces the `serverUrls` key, confirming the manifest wiring
against a real DPC. Setting a value and relaunching shows the preset as a
tappable row on the connect screen, and the app does not auto-connect.
## Demo
Visible change is additive: preset URLs appear as tappable rows in the existing
server list on the connect screen and in the host-pill switcher menu. Unmanaged
installs are pixel-identical to before — no new views or strings on that screen.
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
`ManagedConfigTest` covers the parse layer (absent bundle, missing key, blank
value, mixed delimiters, scheme defaulting, dropped bad entries, origin dedupe,
the cap, and origin-based `includes`). `ServerStoreTest` covers precedence: a
preset is offered but never becomes current, several presets are all offered,
connecting is what makes one current, and presets lead the offered list while
covering same-origin recents. `MainActivityTest` asserts a preset never
overrides the server the user picked.
Manual verification was needed for the parts no unit test can reach: that a real
DPC renders our restriction schema, and that the key name matches what an EMM
pushes. Done on an emulator with Test DPC as device owner, as described above.
## Changelog
Organizations can preconfigure Omnigent server URLs through Android managed
configuration, and they show up ready to tap in the app's server list.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
The duplicate-check comment asked reporters to close their own issue and
add details to the match, but never looked at whether the match was still
open. On #4245 it pointed at #1977 — closed as completed a month earlier
— so both asks were wrong: a shipped fix means a regression or an old
build, and details added to a closed issue go nowhere.
This is the common case, not an edge case. The corpus is deliberately
`--state all` so old reports stay discoverable, and 65% of top-ranked
candidates over the last 40 issues are already-fixed issues.
Comments now branch on the reference's own state:
- open — unchanged; the reporter can still move their report there.
- closed as completed — leads with the shipped fix and asks whether they
are on a build that includes it, keeping the issue open as a regression
if it still reproduces.
- closed as not planned (or `wontfix`) — points at the reasoning with no
self-close ask, since there is no live discussion to move into.
`stateReason` is plumbed through the corpus fetch and candidate
normalization; a missing disposition falls back to the open wording,
which asks rather than asserts. Mixed sets name each group separately so
a declined issue is never described as fixed.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(runner): suppress KeyboardInterrupt traceback on zygote Ctrl+C
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fixup! fix(runner): suppress KeyboardInterrupt traceback on zygote Ctrl+C
Add explanatory comment to the except block.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fixup! fix(runner): suppress KeyboardInterrupt traceback on zygote Ctrl+C
Use contextlib.suppress per SIM105.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(server): show "Starting up…" for SDK sessions, not "Connecting…"
Creating a polly/debby session in the web UI showed a small "Connecting…"
wheel *below the composer* instead of the "Starting up…" spinner that
claude-code and codex sessions render in the conversation.
Both indicators key off `isTerminalFirst`
(`labels["omnigent.ui"] === "terminal"`). Native wrappers stamp that
label at creation, but a non-native session's runner stamps it in
`_auto_create_repl_terminal` only *after* the REPL terminal exists —
which is exactly when `terminalStartingUp` goes false. The window where
the label is present and the spinner condition still holds was therefore
empty by construction, so these sessions always fell through to the
passive "Connecting…" band.
Stamp the label at session creation for the same set whose runner
auto-creates the REPL terminal. The predicate mirrors the runner's own
gate (non-native harness, top-level session); the caller adds
`host_id is not None` so an in-process, runner-less session never shows a
Terminal pill it cannot open, and `harness_override == "auto"` is
excluded because the first-message router has not picked a harness yet.
No web changes: these sessions were already terminal-first once the
runner's later stamp landed, so this only moves the transition earlier.
Setting the label also enables the eager `terminal_pending` publish,
giving continuous spinner coverage; the runner's `finally` clears it,
with the `session.resource.created` self-heal as backstop.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The landing composer awaited the create POST — session bootstrap plus a
runner launch, so seconds of it — and then navigated unconditionally.
That closure outlives the composer's unmount, so a create that landed
after the user had opened another session yanked them into the new one,
tearing them out of the session they had deliberately gone to.
Gate the post-create navigation on the composer still being on screen.
The session is created either way and its first message stays held, so
opening it later still dispatches the prompt.
Flipping the "this draft is spent" flag on the response was too late for
the same reason: the unmount cleanup now runs while the create is still
in flight, so returning to the landing screen mid-create handed back the
message that had already been sent. Flip it at submit instead, and hand
the draft back when a create fails or is rejected — otherwise a failed
send would eat the user's message.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): even out spacing between collapsed "Worked for" rows
A turn that yields mid-task (dispatching sub-agents, then awaiting them)
folds its whole trace behind the "Worked for" row and carries no answer
of its own. The bubble's copy/fork row is gated on collectBubbleMarkdown,
which counts every text item -- including narration sealed inside the
fold -- so such a bubble grew a 28px action row plus 12px of margins
whenever its HIDDEN trace happened to narrate. Consecutive collapsed
rows then sat 16px or 56px apart with nothing on screen to explain it.
Skip the actions on a bubble that renders nothing but the collapsed row;
bubbles with a visible answer keep them, under the answer. The fold
predicate moves into a shared pure isFoldEligible/rendersOnlyWorkedFold
so the bubble asks the renderer's own question instead of restating it.
Those rows also lost their trailing hairline: MessageContent is w-fit, so
a bubble holding only the summary row shrank to ~110px, collapsing the
rule's flex-1 span to zero and cutting the click target short. Give them
w-full at the existing max-w-3xl cap -- not the full-column width isWide
grants, which on >=1921px screens would push these rules wider than
answered turns' and misalign them.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
Workflow runs for this PR were dropped by the GitHub Actions incident
(webhooks throttled to ~15%); an empty commit re-fires the triggers.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(web): pin the settled status the fold-only cases depend on
The fold-only assertions turn on `possiblyLive` being false, which they
were getting from the store's default `sessionStatus` rather than saying
so. Set it explicitly in the fixture, and note on
`rendersOnlyWorkedFold` that it answers from shape and liveness alone —
so across the renderer's settle window the two decisions may differ for
a beat, which costs nothing on a bubble with no answer to anchor.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
## Related issue
N/A — chore, no issue required.
## Summary
- Bumps the Android shell's `versionName` from `0.1.0` to `0.1.1`.
- `versionCode` is intentionally untouched: it is supplied per release by CI
(`android-bundle.yml` passes `-PversionCode=<input>`, documented as "must be
higher than the last uploaded to Play; starts at 3"). The `?: 2` in
`build.gradle.kts` is only a local-build fallback, so changing it would have
no effect on what ships to Play.
## Test Plan
- `./gradlew :app:processDebugMainManifest` and inspected the merged manifest:
```
app/build/intermediates/merged_manifest/debug/processDebugMainManifest/AndroidManifest.xml
android:versionCode="2"
android:versionName="0.1.1"
```
- `pre-commit run --files web/android/app/build.gradle.kts` — passes.
## Demo
N/A — no visual change.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [x] Not applicable
## Coverage notes
A version-string constant has no behaviour to unit test. Verified by building
the merged manifest and confirming `android:versionName="0.1.1"` is what the
build actually emits, rather than only reading back the source line.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* fix(chat): create fresh sessions by agent_id on a remote-URL target
Connecting to a remote server with `omnigent chat <url>` could discover
the server's registered agents but never start a conversation with one.
Both entry points assumed a local agent bundle was available to upload:
- Interactive chat raised "Sessions API fresh session creation requires
a local agent bundle" from the REPL adapter, before any network call.
- Headless `-p` fell through to the legacy `/v1/responses` endpoint,
which the server no longer exposes, so the turn failed on a bare
"Not Found".
A remote target has no bundle to upload by definition: the agent is
already registered server-side. The server has long accepted a JSON
`{"agent_id": ...}` body on POST /v1/sessions (the route the web UI's
new-chat flow uses), so the client just needs to use it.
Add `sessions.create_from_agent_id()` and `sessions.resolve_agent_id()`
to the Python SDK, then take that path in both places when no bundle is
present. The headless fix goes in the shared `_query_sessions_once` so
the no-bundle case is handled once, for every caller, rather than in a
second branch per entry point; that also retires the dead legacy
fallback and its now-unused event imports.
An unknown agent name now fails with a LookupError naming the agent and
listing what is registered, instead of a confusing session-create error.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(chat): narrow bundle type, paginate agent lookup, keep /model pick
Addresses the pyrefly failure and Polly's review notes.
The flat if/elif chain in _ensure_session left self._session_bundle
typed as `bytes | None` at the multipart create call, which pyrefly
rejected. Split the two create paths into their own methods so each
one narrows what it needs, leaving _ensure_session as create-or-resume.
resolve_agent_id now follows the /v1/agents cursor, so an agent past
the first page resolves instead of raising a spurious LookupError.
The docstring also notes that the route lists only server-registered
agents, so a session-scoped agent is not resolvable by name.
A `/model` typed before the first turn was applied only on the bundle
path. Hoist that PATCH into one helper both create paths call, so the
pick is no longer silently dropped on a remote-URL session.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(chat): route the third one-shot caller through the sessions API
Found while manually QAing this branch: `omnigent run --server <url> -p`
still failed with `Not Found`. That path goes through `_run_one_shot`,
a third caller I had missed — it gated on `session_bundle is not None`
the same way and otherwise fell back to the legacy client query.
Drop the gate so it uses `_query_sessions_once` like the other two
callers, which already picks the create route from whether a bundle
was supplied. Add an E2E guard that fails with the same `Not Found`
without this change.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(chat): adopt an online server runner for remote-URL sessions
Review caught that the new tests injected a runner_id the real
remote-URL entry points never supply. Both `run_chat` and `run_prompt`
pass runner_id=None for a URL target, and I confirmed against a live
server that this still failed on the first turn: headless raised before
the new create path ran, and interactive created the session but then
failed the runner-binding precondition.
A URL target gets no host daemon (`--host` is a documented no-op there),
so the client has no runner of its own. But the server does: GET
/v1/runners lists the online runners owned by the requesting user along
with the harnesses each advertises, already ownership-scoped. Resolve the
agent's harness from GET /v1/agents and adopt a runner that advertises
it, so a fresh remote session can dispatch.
Both entry points now complete a real turn with runner_id=None. When the
server genuinely has no online runner, the error points at
`omnigent host --server <url>` rather than the --server flag the user
already passed.
Tests now pass runner_id=None to mirror production wiring, plus guards
for the no-runner error and for the JSON create route keeping its full
snapshot shape (create_from_agent_id parses it without a follow-up GET).
Also caps the agent-name list in the LookupError message.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(chat): canonicalize harness names when adopting a server runner
Review flagged that runner adoption matched harness names raw while the
server canonicalizes first (`_runner_supports_harness`). Confirmed the
gap: with a runner advertising `claude-sdk`, an agent whose spec says
`claude` resolved to None and surfaced "no online runner" even though a
compatible runner was online. There are 17 such aliases.
Pass a canonicalizer into resolve_online_runner and compare both
spellings on both sides, matching server semantics. The SDK is a
standalone package and must not import from `omnigent`, so the callers
inject `canonicalize_harness` rather than the SDK reaching for it.
Also from review:
- Skip the GET /v1/agents round-trip when the agent id is already known
AND a runner is already bound (nothing needs the harness then).
- Drop `resolve_agent_id`: it had no callers after the switch to
`resolve_agent`, so it was dead public API rather than intended surface.
Adds a parametrized guard covering both alias directions; it fails
without the canonicalizer, which is the reported bug.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(antigravity-native): scope the CLI agy launch to a per-session gemini dir
The runner-owned (web) launch already pointed agy at an isolated
`--gemini_dir` and wrote the Omnigent MCP relay config there. The CLI launch
(`omnigent antigravity` -> `_launch_and_record`) did neither, so agy read the
user's real `~/.gemini`. Two consequences:
- No Omnigent relay in the config agy actually loads, so the wrapped agy had
no `sys_*` tools at all — the residual half of #1194 that the host-spawned
fix (#1216 / #1598) never covered.
- The survey/trust seeds rewrote the user's own
`~/.gemini/antigravity-cli/settings.json`, which is precisely the clobber
the isolated-dir design exists to prevent.
Mirror the runner path: `write_mcp_config` + `seed_isolated_agy_home` (trusting
the CLI cwd) and prepend `--gemini_dir=<isolated dir>` ahead of every generated
flag. `HOME` stays real, so agy's keyring-backed OAuth (macOS Keychain) still
unlocks — deliberately NOT relocating HOME, which is the regression #1598 undid.
Two related cleanups found while tracing this:
- `ensure_agy_onboarding_complete()` wrote the real `~/.gemini` on BOTH launch
paths for a marker agy no longer reads: `seed_isolated_agy_home` already
writes the identical file into the isolated dir, before launch. Dropped from
both callers, so nothing writes the user's tree any more. The function is
kept and marked `deprecated:: 0.9.0` (remove in 0.10.0) since it still has
dedicated tests.
- Added `google_accounts.json` to `_AGY_SEED_FILES`. It sits beside
`oauth_creds.json` on a signed-in Mac (confirmed on macOS 26.5.2); without it
agy can hold a valid token yet still prompt for account selection in a fresh
Gemini dir. This is the one-line seed #1477 asked for that never landed.
Also corrected three comments this falsifies, including one asserting macOS runs
agy under the real `~/.gemini` as "the #1477 Keychain trade-off" — no longer true
on either path.
Verified on macOS 26.5.2 (arm64) with `dev/verify_agy_gemini_dir.py` (added): it
drives the real launch path against a redirected fake HOME, so it needs no
server, runner, or real agy and is safe on a signed-in machine. 3 failures
pre-fix -> 0 post-fix. 144 agy unit tests pass; the new regression test fails on
unfixed code. Live `/mcp` confirmation still needs an `agy` install.
Part of #1477
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Isaac
* fix(dev): drop hardcoded model id from the agy gemini-dir verifier
The `no-hardcoded-models` pre-commit hook excludes `tests/` but not `dev/`,
so the placeholder settings value tripped it and failed CI. The value only
has to be a user setting the launch must leave untouched, so an opaque
string works just as well.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(server): stop a runner drop from failing finished sub-agents
Sub-agents ride their parent's runner, so a tunnel drop reaches every
child bound to it. `_on_runner_disconnect` marked all of them `failed`
regardless of whether they were mid-turn, and published the edge with no
`ErrorDetail` — so an Agents rail full of sub-agents that had completed
successfully went red, with nothing recording why.
The missing cause also made the state sticky: `_publish_runner_recovered_status`
only clears a failure it can identify as a disconnect, so the fan-out's
unlabelled `failed` survived a reconnect until the next `running` edge.
Only the per-session relay wrote the cause, and a session whose stream
already ended on `[DONE]` has no relay left to write it.
Both callbacks now go through `_mark_runner_sessions_offline`, which
skips sessions that were not mid-turn (cache first, the persisted
`live_status` as fallback), skips an intentional Stop/archive teardown,
and stamps the cause on the ones it does fail. `_on_runner_exited` passes
`fail_idle_top_level=True` so a runner that died before it could run
anything still surfaces on its top-level session; an idle sub-agent is
skipped either way, since its runner was already live.
No frontend change: `subagentStatus.ts` already renders a
`runner_disconnected` / `runner_failed_to_start` cause as a quiet
"Disconnected" rather than the red "Failed" — it was never given the data.
Addresses Gap 2 of #1113.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the runner-disconnect fan-out end to end
The unit tests cover the reconciliation decision, but the wiring lives in
a `create_app` closure that cannot be imported. Drive a genuine WS close
on a dedicated runner with two sessions bound to it — one mid-turn, one
idle — and assert the idle one is untouched while the interrupted one is
failed with `runner_disconnected` labels.
Binds through the store rather than a PATCH so no relay spawns: the relay
reacts to the same close, which would leave it ambiguous which path
produced the labels.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
This PR was opened during a GitHub Actions dispatch outage (no
pull_request workflow runs were created repo-wide between 20:50Z and
22:41Z), so its opened / synchronize / ready_for_review events were all
dropped and no checks ever ran. Empty commit to fire a fresh
synchronize now that dispatch has recovered.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
Dispatch for `pull_request` workflows has been intermittent repo-wide;
this PR's earlier events landed in a dead window. Firing a fresh
synchronize while dispatch is confirmed working.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the crash-report flag against interrupted and stopped turns
Two gaps in the reconciliation matrix: a mid-turn sub-agent under
`fail_idle_top_level` (a crash report must never downgrade an
interrupted turn), and an intentionally stopped session under the same
flag (the Stop/archive skip still wins).
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): name the runner log file in "see runner logs" errors
`omnigent codex` (and its siblings) surface the runner's message verbatim, so
a failed native terminal start read:
Codex terminal ensure failed (500): Native Codex terminal failed to start;
see runner logs for details.
which left the user hunting for a file whose name they could not know. The
runner already knows its own log path — the host passes it as
OMNIGENT_PROCESS_LOG_FILE when it spawns the subprocess — so name it:
... failed to start; see the runner log for details:
~/.omnigent/logs/runner/runner-<session>-<timestamp>.log
Same treatment for the generic runner detail string (_client_safe_error_detail,
~40 call sites: harness spawn, spec resolve, model change, compact, MCP
dispatch). The client-safe contract is unchanged: the raw cause still goes to
the log only, and the path is home-relative so it points somewhere without
leaking the account name.
process_logging grows current_process_log_path() / process_log_reference() to
publish the path, and display_log_path() is promoted out of host/connect.py
(it was private there) so both sides format paths the same way. The
daemon_launch "runner did not connect" message stops hardcoding
~/.omnigent/logs/runner/ and computes the real dir, so it is correct under
OMNIGENT_DATA_DIR.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(runner): pin the runner log path instead of trusting test order
The three tests asserting the new "see the runner log for details: <path>"
messages set OMNIGENT_PROCESS_LOG_FILE and expected the message to name it.
That holds only until some earlier test in the same xdist worker runs the real
configure_process_logging: test_runner_entry's
test_main_preserves_unexpected_runtime_errors calls main() without stubbing it,
which allocates ~/.omnigent/logs/runner/runner-<timestamp>.log and publishes
that path process-wide. The published path outranks the environment (it is what
the process actually logs to), so the assertions saw the leaked path and the
runner-app group failed in CI while passing when run alone.
Pin both sources in one place: a pinned_runner_log fixture in
tests/runner/conftest.py sets the published path and the env var, so the
assertions hold whatever else the worker ran first.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The rename's optimistic cache write reaches the row as a prop from the
sidebar list above it, which re-renders a tick after the row's own
`setIsEditing(false)`. For that one frame the row repainted the
pre-rename title as the inline editor closed.
Hold the committed title in the row until the prop carries it, or until
the PATCH settles so a failed rename rolls back to the old name.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): don't fetch history when opening a session
Opening a session kept loading older history for seconds after the page had
settled, shifting the transcript under a reader who had never scrolled. On a
real session that was 15 requests and a "Loading earlier messages…" row, for
someone who hadn't touched the scrollbar.
Two things drove it. bindStream rendered one 20-item page and HistoryAutoLoader
then paged from a layout effect until it found the previous user prompt. And
the scroll rule was "scrollTop is near the top", which the open satisfies by
itself: the pane scrolls to the bottom on load, and on a transcript shorter
than the fetch threshold that lands trivially near the top — so it fetched, the
prepend moved the cursor, and that fed the next fetch.
Fetch the window in one larger request at bind, and page only when the reader
asks. "Asks" is the gesture, not the movement: a pane shorter than the window
has no scroll range, so waiting for scrollTop to fall would strand older
history behind a scroll the pane can never report. A wheel-up or a downward
touch drag arms paging whether or not the pane has anywhere to go.
Also cap the trailing spacer at a third of the viewport, so a short latest turn
no longer reserves most of the screen as blank.
Measured on a real session, sitting still: 15 items requests -> 1, 13
transcript height steps -> 1, and the loading row never appears.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(ui-snapshot): update the turn-rail baseline for the capped spacer
Capping the trailing spacer at a third of the viewport means a short latest
turn no longer pushes everything to the top, so the preceding exchange stays
on screen. Adopted from the gate's own render (update_baseline_from_pr.sh).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): fetch one window on reconnect too, and drop the dead page walk
The reconnect gap-close still grew its window with the multi-page
prompt-boundary walk, so the two paths that replace the whole transcript had
started to diverge — and its docstring's "exactly as a cold bind would" was no
longer true. That path fires off a dropped stream, so the reader didn't ask for
it either; paging it in over several requests shifts the transcript under them
for the same reason opening a session used to.
Point it at the same single window fetch. That leaves fetchInitialHistoryWindow
with no callers, so remove it along with MAX_INITIAL_PAGES / isUserPrompt /
initialWindowComplete and the tests covering it.
test_transcript_scroll_stability seeded 30 turns (60 items) to guarantee older
history beyond a 20-item window; a 100-item window swallows the whole
transcript, so its scroll-up had nothing to fetch. Seed past the new window
instead of relaxing what it asserts.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(ui-snapshot): re-render the turn-rail baseline after merging main
Main and this branch both moved this baseline, so the merge conflicted on it.
Neither side is right on its own — the correct image is a render of the merged
code (main's chat/sidebar polish plus this branch's capped spacer). Adopted
from the gate's own render of the merge commit.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix: let a healthy route finish before the routing hook gives up
The first-message ladder was sized from the routing call alone, but the
server prepares the candidate catalog before it calls the router — about
three seconds on a first message. A healthy route therefore cost ~4.8s
against a 7s relay budget that started earlier, so the runner abandoned
verdicts that did arrive: the attempt was wasted, the prompt was replayed
a second time, and the transcript showed it twice.
Each hop now covers preparation plus the call, with the hook budget at the
15s ceiling and the harness kill still under Claude Code's own 30s
UserPromptSubmit default. A wedged router costs 15s instead of the 45s it
cost before this ladder existed. The magnitude test gains a floor as well
as a ceiling, so a future tightening cannot re-open the gap.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): say claude and codex on spawn chips, without the native suffix
A spawn chip's harness id is how the spawn runs, not something the chip
needs to spell out; the native suffix reads as noise there. SDK-brain
sub-agents (a bundle agent's codex / claude-sdk children) carry no suffix
and render unchanged, as do the session's own session/turn chips.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: align the spawn-gate budget assertion with the widened ladder
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): keep a pinned session's spawns in its own family at the source
A pinned Smart Routing session was offered every agent by
``sys_agent_list``, so a codex session could stand up a claude-native
child and only then have routing decline it. Refuse the spawn before it
happens instead:
- ``sys_agent_list`` drops built-ins outside the caller's family when the
caller routes its spawns and is not auto-harness.
- ``POST /v1/sessions`` refuses an out-of-family child of such a parent,
naming the rule.
Auto-harness parents still cross families (the router owns theirs), and a
plain session sees and spawns exactly what it did before. The routing
decline stays as the fail-safe for a pane that exists anyway.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): decline a route-turn whose parent routes another family
``route_turn_hook`` routed a pane's first typed prompt in the pane's own
family with no look at its parent, so a child pane on another family's CLI
could be pinned to a model its parent's family serves and the pane cannot
speak. The policy now declines (fail-open, nothing pinned, no chip) when
the pane's parent is a pinned Smart Routing session of another family.
The create gate refuses such a pane outright, so this only catches a row
that predates it — hence non-terminal, and the parent's switch stays
togglable.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): a failed auto-harness route must not claim the route-once label
The auto-harness path stamped the routing-decision label on its own
"unavailable" card, and that label is the route-once gate — so a router
that happened to be down when the session started made every later
in-harness prompt decline as "already routed". Leave the label unclaimed
on failure, the way the turn, native-pane and child-spawn paths already
do; the declined card still says what happened.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): stop routing a Smart Routing create's prompt twice
A native Smart Routing create routes the landing screen's prompt and pins
what it picked; the harness then submits that same prompt, and the
first-prompt hook scored it again — a second judge call tens of seconds
later, for the verdict the pane was already running on, and a needless
block-and-replay of the turn.
The create now fingerprints the prompt it routed (a hash: the label is
metadata, and the user's prompt does not belong there). When the hook sees
that prompt again it claims the create's decision instead of making a new
one — one router call, one chip. A prompt the user edited before sending
does not match and still routes on its own, as does the first prompt of a
session whose create-time route failed.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* perf(routing): take catalog preparation off the turn path
A first routed message spent ~3.2s preparing routing candidates before the
routes:select POST went out, and nothing in the logs named where it went. Two
runner-derived catalogs were being resolved while the user's prompt was held:
the claude-native picker vocabulary, whose stale entry the turn path awaits for
up to _ROUTING_CATALOG_WAIT_S (3.0s) while the fetch retries a booting runner,
and the runner model catalog, a round trip per turn for every pane that has no
picker vocabulary of its own.
Warm both when the runner binds instead. _on_runner_connect now calls
prefetch_session_routing_catalogs once the session-init handshake has created
the terminal, so the catalogs land before the first prompt rather than under
it. The runner catalog also gains a per-session cache behind _fetch_runner_catalog
(single-flight, 5-minute backstop TTL) whose entries drop through the seam that
already invalidates runner-derived snapshot overlays — a rebind or relaunch can
change which models a pane accepts, so it must not keep routing off the previous
runner's list. A cold cache still takes the inline fetch, so nothing depends on
the prefetch having run.
route_turn now logs its two phases separately (prep vs router) and the stale
catalog refresh logs what it waited, so the timeout ladder can be revisited
against measurements instead of a guess. The ladder constants are unchanged
here.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(codex): check a routed slug is reachable before switching the pane
The routing verdict comes from a server-side gateway map that can go stale, so
the routed model is not necessarily one this pane's gateway serves. The hook
switched onto it regardless: codex accepted the id, the next turn failed, and
nothing anywhere said why — the failure mode the #4074 review flagged.
The pane's live model/list is the only authority on what it can be moved onto,
and the hook already reads it to translate the routed id into codex's spelling.
Make that read the reachability check too: codex_model_slug becomes
codex_reachable_model_slug and answers None when no row names the model, and
_apply_thread_model returns a decline reason instead of a bare bool. An
unreachable pick leaves the pane on its own model, writes no marker, blocks
nothing, and records "routed model not in this pane's catalog" to the routing
trace and stderr — the same fail-open shape the claude side uses when a routed
model has no spelling its picker accepts.
A model/list that cannot be read is now distinguished from an empty catalog and
also declines: an unreadable catalog is not evidence of reachability, and
declining costs a turn of routing where switching blind costs the turn itself.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(auth): one workspace identity, and a refresh that can fall back
Two credential faults that made a healthy workspace look unreachable.
**One identity.** A pane and the server could authenticate as different
~/.databrickscfg profiles for the same host. The server's router client uses
the config's `kind: databricks` provider profile; the claude-native pane
installed ucode's recorded token command, which selects the workspace however
ucode was set up — usually by host. Two profiles on one host are two
identities, so re-authing one left the other's token expired and the two halves
disagreed about whether the workspace was up. The named profile is now the
authority on both sides: the pane's apiKeyHelper is regenerated against it
(only for the recognizable `databricks auth token` shape — an enterprise
deployment's own token command has a selector we have no business guessing at),
and a `routing:` block that names no profile falls back to the provider block's
rather than to the ambient SDK chain. Host selection stays the fallback for
when nothing names a profile.
**A refresh that can fall back.** The generated helper forced a refresh on
every call. The reason is real — `--force-refresh` renews a still-valid token
and keeps a long gateway session off a mid-session 401 — but it fails outright
once the refresh token has gone stale, which turned a perfectly usable cached
access token into a hard auth failure (twice in one day). The forced attempt is
now speculative: its output is captured, its stderr dropped, and an empty
result falls back to plain `auth token`, which serves the cached token and
renews it near expiry. The fallback keeps its stderr so a genuine auth failure
is still visible.
Both harnesses generated this command separately, so the shape now has one
definition (databricks_bearer_token_command) and the claude and codex helpers
delegate to it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: align both hook-budget assertions with the widened ladder
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: keep the catalog-cache reset import-free; cover the spawn chip in e2e_ui
The autouse cache-reset fixture imported omnigent.server.smart_routing in
every teardown, which detonated inside the spec suite's import-blocker
test and taxed lanes that never load the server. A sys.modules lookup
clears the cache only where it exists. The new Playwright case pins the
shortened spawn-chip harness label the UI judge flagged.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: leave a visible declined chip when the turn hook's routing call fails
The create and dispatch paths already card a failed route; the in-harness
first-message hook failed open silently, so a router 401 looked like the
session simply ignoring Smart Routing. The hook now persists the same
unavailable card with the cause, without claiming the route-once label —
the next prompt can still route. Benign allows (already routed, routing
off, the family guard) are not failures and stay chipless.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(cli): drop create-time Smart Routing; keep first-message routing
The CLI can only route a prompt it never shows: `--smart-routing -p` picked a
model (and, on `run`, a harness) before the TUI existed, so the user typed at a
session whose pick they could neither see nor change. The web UI is the surface
that can do that. So the CLI keeps the one routing shape a terminal can honour
— arm the session, let the harness's own hook route the first message typed —
and rejects the rest.
`omni claude|codex --smart-routing` stay, bare only. `-p` alongside them is now
a usage error pointing at the TUI or the web UI, and `run --smart-routing`
(with it the CLI's auto-harness route) is rejected outright; its flag stays
hidden purely to say where routing moved, and comes out in 0.11.
That leaves nothing behind the create-time path: the routed create no longer
sends a message or the `auto` sentinel, reads back no verdict, and the
launch-side plumbing that applied one is gone. `create_smart_routing_session`
becomes `arm_smart_routing_session` and `RoutingDecision` becomes
`ArmedSession` (session id + fail-open notice), because neither decides
anything any more. The preflight gate, the `--resume` rejection and every
server-side create path are untouched.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): drop "-native" from every routing chip, not just spawn chips
A session-scope chip read "codex-native", which leaks how the pane runs
into a label that only needs to name the brain. The shortening was scoped
to sub-agent decisions; it belongs on every chip, so harnessDisplayLabel
no longer takes a scope and always trims the trailing suffix. SDK ids
(codex / claude-sdk / auto) carry no suffix and render unchanged.
The e2e session-chip assertion now also pins the negative: a bare
"claude" substring-matches "claude-native", so only not_to_contain_text
catches a regression. Same for the card unit test, which anchors on the
full label.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): render an auto-harness create chip below its prompt
A session created with Smart Routing as both the model AND the harness
records the pick as a `session` chip at create time, and its first turn
routes again and records a `turn` chip — so two chips sit above the
session's first user message. `deferredRoutingChips` only paired a chip
whose immediate next content block was that message, so the first of the
two was left in place and rendered ABOVE the prompt, reading as a
preamble instead of the verdict on it. It only looked right when the two
verdicts matched and the create chip was dropped by the collapse.
Look forward past the sibling chips waiting on the same message (and
past superseded ones, which render nothing) and defer them all below the
message, in transcript order. A sub-agent chip still stops the scan: it
renders standalone where it occurred, and stepping over it would reorder
the two. The cache's pending-pair guard learns the same rule so the pair
stays stable frame by frame.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* perf(runner): skip the sys_agent_list routing lookup on plain sessions
Family confinement made every sys_agent_list pay a serial
GET /v1/sessions/{id} with a 30s budget before discovering the session
was not routed at all. Plain sessions — the overwhelming majority —
carried seconds of fan-out latency for a feature they never use, and a
wedged server stalled the listing for the full 30s.
Read the runner-local routing class first: a session with no routing
armed, or an auto-harness one, answers without a server hop. Only a
locally pinned routed session spends the lookup, now on a 5s budget that
fails open to the unfiltered listing, and its answer is cached for the
session (routing state is fixed at create). The create-path gate still
refuses out-of-family creates, so a fail-open listing stays safe.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(auth): fall back to ucode's recorded token command
Pinning the pane's apiKeyHelper to the config-named Databricks profile
fixed one outage and opened its mirror image: when the named profile
holds no usable credential — a config naming DEFAULT while the user
authenticated under another profile on the same host — the helper now
prints nothing and every turn 401s, where before the rewrite ucode's own
recorded command served a working token.
The named profile stays the preferred identity; the recorded command
becomes the helper's last resort, after the forced refresh and the cached
token have both come up empty. An injected DATABRICKS_BEARER still
short-circuits everything.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* perf(routing): only warm catalogs for routed, live sessions
A runner reconnect walks every session bound to that runner, and the
catalog prefetch fired for all of them — archived rows included — with no
Smart Routing gate. One host's tunnel flap with ~25 plain codex panes
launched 50 fire-and-forget tasks whose provider listings run on worker
threads, so the session re-init running alongside them timed out and the
panes came back stranded, all to warm a cache only Smart Routing reads.
Gate the prefetch on the canonical routing reader
(routing_class_from_snapshot), skip archived sessions, cap concurrent
warm-ups with a small semaphore, and have each task retrieve its own
exception: a tunnel dropped mid-prefetch raised RuntimeError that nothing
ever retrieved, which surfaced only as asyncio unretrieved-exception
noise.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(routing): route a pinned native create before its pane launches
Picking Claude Code or Codex with Smart Routing as the model created the
session with no prompt to route on, so routing fell through to the
in-pane first-message hook: the prompt was blocked, routed, switched with
`/model` and replayed. The user watched their own message disappear for
seconds, and the composer's model pill stayed stale because the pin
landed mid-turn instead of before the snapshot bound.
The web create now sends `smart_routing_message` for a pinned
claude-native / codex-native pane too, whenever routing owns the model.
The server already routes the MODEL only on that path and pins
`model_override` before the terminal launches; the client still delivers
the real first message after navigation, exactly as the auto path does.
Bundle agents are untouched — their harness isn't decided until the first
message event, so there is nothing to route at create.
With the model pinned and the routing-decision label stamped before the
pane exists, the `UserPromptSubmit` turn-routing hook has no answer left
but "already routed" — paid for with a held prompt and a round trip per
prompt. The session's routing class now carries a `turn_routing` flag
that drops to false once the row has a routing decision, and the native
launch skips the loopback router; the absent advertisement is what leaves
the hook out of the generated settings. A create whose routing failed
stamps nothing and keeps its hook, so the first message is still its
retry, and spawn routing plus the extended catalog are untouched.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): keep a create-time routing chip below the prompt it decides
A pinned Smart Routing create routes at create time, so the session-scope
decision is persisted before the pane launches while the landing composer's
prompt is only posted after navigation. The prompt is on screen the whole
time, but as an optimistic `pendingUserMessages` entry merged in AFTER the
bubble walk — never a `user_message` block — so `pairableMessageAfter` cannot
see it and the chip renders above the message until the server persists it,
then visibly moves below.
Splice the pending prompt above a run of session-scope chips that opens the
committed timeline, matching the position `buildBubbles` gives the chip once
the message is persisted. The chip renders once, below the prompt, and stays
put across the pending → committed swap. Chips anywhere else (paired with
their message, or a standalone sub-agent spawn) keep their place, and a chip
with no message — including a declined create route — still renders.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* chore: trigger CI on the rebased tip
The rebase onto main and the chip-ordering fix never ran the test lanes;
only CodeQL and DCO reported.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
## Related issue
Closes OMNI-2485 — https://linear.app/omnigent/issue/OMNI-2485
## Summary
- The Android shell previously sent *every* login through the system browser:
it stopped any off-origin navigation, requested a CLI-style ticket, opened
the browser, polled for the session JWT, then injected it as a cookie
(`OidcLoginManager`). That detour exists only because Google's OAuth endpoint
rejects embedded webviews — the browser and WebView have separate cookie
jars, so the session has to be carried across by hand.
- Databricks-hosted deployments authenticate via Okta, which permits embedded
user-agents. For those servers the whole detour is unnecessary: the redirect
chain can run inline and the server sets the session cookie on its own
domain, so nothing needs bridging.
- Adds `usesInWebViewAuth()` in `Origins.kt`, keyed on the **pinned server**
(`databricks.com`, `azuredatabricks.net`, `databricksapps.com`). When it
matches, off-origin navigation loads inline instead of triggering the browser
hop. `OidcLoginManager` is untouched and still handles every other server.
ELI5: the app used to kick you out to Chrome to log in, then smuggle the
resulting session back in. On Databricks servers it no longer needs to — you
just log in where you already are.
Keying on the pinned server rather than the destination is deliberate: during
login the WebView navigates to `databricks.okta.com`, so a destination
allowlist would have to enumerate IdP domains it can't know up front.
```mermaid
flowchart LR
A[off-origin nav] --> B{pinned server uses<br/>in-WebView auth}
B -- no --> C{gesture}
C -- yes --> D[system browser]
C -- no --> E[browser hop:<br/>ticket, poll, inject cookie]
B -- yes --> F{gesture AND<br/>on a pinned-origin page}
F -- yes --> D
F -- no --> G[load inline]
```
The gesture check is qualified by "on a pinned-origin page" because once the
WebView is on the IdP's own pages, its sign-in buttons and form posts are both
off-origin *and* gesture-driven — without that qualifier they get mistaken for
external links and ejected to the browser mid-login.
Safe because the native bridge is origin-allowlisted to the pinned origin by
WebView itself (`addWebMessageListener` / `addDocumentStartJavaScript` are both
passed `setOf(origin)`), so an IdP page loaded in this WebView cannot reach it.
Host matching uses a dot boundary (`host == d || host.endsWith(".$d")`) so a
lookalike like `databricks.com.example.org` does not qualify.
## Test Plan
- `./gradlew :app:compileDebugKotlin :app:compileDebugUnitTestKotlin` — clean.
- `pre-commit run --files <changed>` — ktlint format + check pass.
- New unit tests: 6 cases in `OmnigentWebViewClientTest` (inline IdP redirect,
browser hop for other servers, external link from the app page, sign-in tap
on the IdP page, both `onPageStarted` branches) and `OriginsInWebViewAuthTest`
for the dot-boundary matching.
- On-device against `https://omnigents-<id>.aws.databricksapps.com`: login
completes entirely in-app through Okta (Okta Verify), no browser launch and
no "Signed in" notification. `adb logcat -s OmnigentAuth`:
```
off-origin nav https://ai-oss-...cloud.databricks.com gesture=false
off-origin nav https://ai-oss-...cloud.databricks.com gesture=false
off-origin nav https://ai-oss-...cloud.databricks.com gesture=true
off-origin nav https://databricks.okta.com gesture=false
off-origin nav https://databricks.okta.com gesture=false
off-origin nav https://ai-oss-...cloud.databricks.com gesture=false
```
Every hop loads inline and `onLoginRequired` never fires. The return to the
pinned origin logs nothing because same-origin loads short-circuit earlier.
## Demo
N/A — no visual change; the difference is the absence of a browser launch. The
logcat trace above shows the new behaviour.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Unit tests could not be executed locally: Robolectric cannot fetch
`org.robolectric:android-all-instrumented` because `repo1.maven.org` is
unreachable from this machine. This is pre-existing and environmental —
untouched tests such as `ThemeTest` fail identically. Compilation of both main
and test sources was verified instead, so CI is the first real run of the new
tests. The end-to-end flow was verified on-device as described above.
Known gaps, both pre-existing and out of scope here:
- Passkey sign-in at the IdP will still fail in the WebView. WebAuthn is off by
default (`WEB_AUTHENTICATION_SUPPORT_NONE`) and enabling it needs Digital
Asset Links published at the RP ID (`databricks.okta.com`), a domain this
repo does not control. Okta Verify and password+MFA are unaffected.
- `shouldOverrideUrlLoading` hands non-http schemes to `Intent(ACTION_VIEW,
url)`, which is wrong for `intent://…#Intent;…;end` URLs (needs
`Intent.parseUri`) and fails silently under `runCatching`.
## Changelog
Signing in to Databricks-hosted deployments on Android now happens in the app
instead of bouncing out to the browser
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* Refine conversation turn rail navigation
Use a single reading-position marker and tighter spacing so the rail is easier to scan and accurately reflects the active turn.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* Refine message hover actions
Use compact, consistently muted controls and tighter spacing so chat actions match the rest of the interface.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* Polish new-session and sidebar UX
Align composer geometry, typography, controls, host context, and project navigation so new-session flows feel consistent and clearly scoped.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* Align selection and compact action styling
Match text selection to active navigation colors and improve compact chat actions with larger glyphs and clearer spacing.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* test(e2e-ui): regenerate visual baselines
* Fix local host label test expectations
Select hosts by stable identity and accept OS-aware local labels so unit and E2E coverage matches the intended UI behavior.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
---------
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* fix(hermes-native): advance the mirror cursor per row, not per item
One Hermes `messages` row expands to several mirror items sharing a
`msg_id` (a reasoning delta, the prose, one `function_call` per tool
call), but the forwarder advanced and persisted `last_id = action.msg_id`
after each item. When an earlier item of a row delivered and a later one's
POST failed, the cursor had already moved past the row, so the next poll's
`WHERE id > last_id` skipped it and the undelivered items were lost
permanently: a silent, unrecoverable drop of an assistant turn's tool call
or prose on any transient post failure mid-row.
Advance `last_id` only at a row boundary, marked by the new
`_TurnAction.last_of_row`. A row that fails partway records
`partial_row_id` / `partial_row_items`, and the retry re-reads that row
with its already-delivered prefix dropped. The prefix-drop is required,
not defensive: `_post_conversation_item` carries no idempotency key, so
re-reading the row without it would mirror the delivered items twice.
The partial row is named explicitly rather than implied as "the row after
`last_id`", because compaction soft-deletes rows and an implied offset
could be applied to the wrong row after the row it describes disappears.
The per-poll heartbeat write and the compaction re-pin both carry or clear
the new fields, so a later poll cannot silently zero them.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(hermes-native): restart the in-row item count on a new row
The in-row delivered count was only zeroed when a row reached its final
item. A row that fails partway can disappear before its retry: compaction
soft-deletes it, and the child re-pin that resets these fields is skipped
when the session has no child (the code logs "staying on parent"). The
stale count then carried into the next row, so that row's retry dropped
undelivered items as already delivered, losing them permanently: the same
silent loss this cursor exists to prevent.
Count from 1 whenever the row is not the one already in progress. Also
pass the partial fields explicitly at the child re-pin write (the one
write site of four relying on dataclass defaults) so a future default
change cannot silently break it.
Found by Polly review on #4261.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): name the vendor, not the Task type, on native sub-agents
A Claude Code sub-agent session read "General-purpose" in the composer
identity slot and "claude-native-ui" in the header breadcrumb. Both are
internals the user should never see: the child row reuses its parent's
`<vendor>-native-ui` agent and stores Claude's own `subagent_type` as
`sub_agent_name`.
The identity paths never consulted the one label that names the product.
`modelPickerKindForConv` matches only `claude-code-native-ui`, so a
`-subagent` child fell through `composerHarnessLabel` to the agent-name
branch; `ChatHeader` rendered `boundAgent.name` raw. Resolve the vendor
from the sub-agent wrapper label instead, so both surfaces read
"Claude Code" (and "Codex" / "OpenCode"), matching the Agents rail.
The sub-agent wrapper map is kept separate from `BY_WRAPPER` so
`isNativeWrapper` still reports false for children — they own no PTY and
take no input.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the native sub-agent identity labels
The `E2E UI Required` gate gives a web/** change a required e2e_ui test.
Register a child through the real `external_subagent_start` contract the
claude-native forwarder uses, so it carries the wrapper label and the
`general-purpose` sub-agent name the identity labels must choose
between, then assert the header and composer read "Claude Code" and that
neither internal reaches the screen.
Verified it fails without the fix: with both branches disabled and the
SPA rebuilt, the "Claude Code" breadcrumb is not found.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI after the GitHub Actions outage
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(web): compute the sub-agent name only for child sessions
Review note: `subAgentName` ran on every render although only the
child-session branch reads it. Gate it on `isChildSession` so non-child
sessions skip the lookup.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): stop background shells from gating the composer and sidebar
When Claude Code's Stop hook fires with background shells still running, the
forwarder relabels the turn-end `idle` to `waiting`. That relabel existed only
to keep a spinner lit, but `waiting` is read as a turn gate everywhere else:
- the sidebar row spins, so a session that takes input reads as busy;
- `waiting` keeps `_session_active_response_cache` populated while the snapshot
projects it as `running`, so opening or reloading the session reopened the
already-settled turn as "streaming" — every message then queued behind
"Steer" and never drained, because the flush refuses to run while streaming;
- the composer offers Stop instead of Send.
Sub-agents already collapsed this back to `idle` (a `waiting` edge skipped the
terminal-delivery branch and hung the orchestrator). The turn has genuinely
ended for a top-level session too, so generalize that collapse: rename
`_subagent_delivery_status` to `_background_task_delivery_status` and drop the
sub-agent gate. Normalizing at server ingress rather than in the forwarder also
covers runners that predate the change. A genuine async-park `waiting` carries
no tally and is untouched.
The background-shell tally still rides the wire and the snapshot, so the in-chat
"N background tasks still running" indicator is unchanged. The tally no longer
forces a `running` sidebar row — it only refreshes on the next Stop hook, so a
spinner keyed off it can outlive the shells it claims are running.
`_best_effort_stop` used that same sidebar rollup as its "anything to stop?"
gate, so it now checks the tally directly — archiving or deleting a session
with live background shells must still stop the runner.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI after the GitHub Actions incident dropped the PR webhook
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (GitHub Actions webhook throttling, attempt 2)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 3, runners recovered)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 4, runner success rate restored)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 5, pull_request webhooks recovering)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 6)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the active-response close on a background-task turn end
The composer bug's mechanism had no direct unit coverage: a `waiting`
turn-end keeps the in-flight response id, and the snapshot projects
`waiting` as `running`, so a reconnect reopened the settled turn as
streaming and queued every send behind "Steer". Assert that delivering
the turn-end as `idle` closes the response while the shell tally survives.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
GitHub's cron is best-effort: the hourly sweep actually fires every 1.5 to 2.5 hours
(measured 09:34, 11:56, 14:10, 16:38, 18:17, 20:23, 22:09, 23:56 today). A
contributor waited that long for the nudge, and just as badly, waited that long for
it to stop applying after they added the issue.
Both scripts now accept PR_NUMBER and fetch that one PR instead of the window. Only
the fetch differs: every exemption, resolution, and dedupe path below it is the same
code, so the instant route and the sweep cannot reach different verdicts.
A new pr-hygiene-live workflow runs both on pull_request_target for opened,
reopened, ready_for_review, edited, and synchronize. `edited` is the one that
matters most after the nudge exists: editing the description to add "Closes #123" is
how a contributor complies, and that should clear immediately rather than in two
hours.
The sweep stays as the safety net. It catches what events miss -- a failed run, and
sidebar issue links, which fire no webhook at all -- and it is the only route that
reaches PRs opened before this workflow existed.
Two guards on the single-PR path, since an event can name a PR the sweep would never
have selected: the EFFECTIVE_FROM floor still applies, so an event on an old PR is
not a licence to reach into the backlog, and a PR that closed between the event and
the run is left alone.
Verified against production with writes blocked: #4173 skip (already nudged), #4187
exempt (maintainer), #4178 ok (has a link), #4104 skip. Each matches the verdict the
sweep reached for the same PR.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(kubernetes): classify managed runner Pods by their agent
Stamp a managed runner Pod with `omnigent.ai/agent: <name>` when the
session is bound to a genuine built-in agent, so an admission policy can
select managed runners by agent and augment their runtime (e.g. inject a
workload-scoped credential). The anti-spoof gate is unchanged
(`session_id is None AND id == builtin_agent_id(name)`), so a user-named
session agent cannot self-classify.
- capabilities: add `classifies_runner_by_agent`, set True only on the
Kubernetes launcher. `_start_sandbox_host` threads `agent_name` into
`start_host` gated on that capability, never by probing the signature —
`start_host` is side-effecting, so a pass-then-retry risks a double
launch. The shared host-launch signature is left untouched, so
exec-model launchers that forward every keyword to `super()` keep
working.
- labels: the value is echo-or-omit — stamped only when the agent name is
already a valid label value, else dropped with a WARNING. It is never
sanitized: the value selects which credential admission injects, so a
lossy collision would cross a credential boundary. The classifier rides
the Pod only, not the launch-token Secret.
- launch: resolve the classifier inside `_run_managed_launch`, on the task
that already owns the single-flight claim. Only the winner resolves, so
no store read is wasted, the claim-to-spawn region stays free of any
await, and the create path does not read the agent store before its 201.
- reserve the `omnigent.sandbox.*` label namespace from client writes.
BREAKING: session create and patch now reject client-supplied labels
under that prefix, which were previously accepted.
- docs: document the classifier lifecycle (fork/switch-agent drop the
label; switching back does not restore it; a running Pod keeps its
launch-time label until replaced), both omit paths and where each logs,
and what the label does not do — namespace RBAC, verifying the creating
identity rather than the label alone, and a fail-closed policy shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: bdchatham <bdchatham@gmail.com>
* test(managed-hosts): establish the relaunch race instead of timing it
test_concurrent_relaunch_messages_kick_a_single_launch is flaky. It failed twice
on this branch and passed either side of both failures, with the code under test
and the test itself byte-identical between a passing and a failing run, so this
is the test rather than a regression.
The race it wants is a message reaching the tracker check while the winner's
claim is still unsettled. Both callers await asyncio.to_thread twice before that
check, and an executor hop takes an unpredictable number of event-loop turns to
deliver, so holding the winner open for five turns does not establish that
ordering. On a loaded machine the racer arrives after the claim settled, takes
the settled-entry retry branch, and kicks a second launch, which reads as the
double-launch this test exists to forbid.
That retry is intended behaviour. In production a second message arriving after
a successful relaunch is turned away by the is_online check further up, which
this test stubs False forever, so the state it was asserting on is one the real
system does not present.
Reproduced deterministically by delaying the racer 50ms inside its thread hop,
which is what a loaded runner does: three failures out of three, with the same
assert 2 == 1 CI reported.
The winner now holds its claim until the racer has demonstrably read the
tracker. That is an ordering rather than a duration, and the test now contains no
sleep, no timeout and no yield count at all — the wait is unbounded on purpose,
since any number there would be a second timing assumption and the suite's own
300s timeout is the backstop. Three reads is the whole exchange, and the count is
order-independent: whichever caller wins, the winner reads twice and the racer
once, and a broken invariant makes both read before either claims, which still
fails the assertion.
Verified in both directions. Under the same 50ms delay that broke the old test
three times out of three it now passes five out of five; twenty consecutive runs
are green; and adding an await between the tracker check and the claim still
fails it with the original assertion, so the guard is intact.
Whole file green at 218 passed including under xdist, ruff clean, and mypy
reports the same 47 pre-existing errors as on the unmodified file.
Signed-off-by: bdchatham <bdchatham@gmail.com>
---------
Signed-off-by: bdchatham <bdchatham@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner's filesystem-changes routes shelled out to git synchronously,
inline on the asyncio event loop:
- `list_filesystem_changes` (the `?view=changed` file panel) →
`list_changed_files` → `git status --porcelain --untracked-files=all`
- `read_environment_file_diff` → `get_changed_file` → `git show` / `git diff`
On a large repository a cold `git status` can take several seconds (a
million-file monorepo measures ~6s here even with the untracked cache
enabled). While that blocking subprocess runs, the runner's event loop
can't service anything else — including the server's runner-stream relay
subscription probe. When a session's first turn (or the changed-files
panel) lands inside that window, the relay misses its readiness budget and
the turn fails with a 503 `runner_unavailable` ("runner didn't come online
in time"). It presents as flaky because it only fires when the git call
overlaps the readiness window — e.g. opening the UI on `?view=changed`
while the runner is still starting up reproduces it reliably.
Offload both git-backed calls with `asyncio.to_thread`, matching the
sibling `get_baseline` call in the same route. The git walk now runs on a
worker thread and the event loop stays responsive regardless of repo size
or cache warmth. Behavior is unchanged (same results, same error
handling); the redundant per-call asyncio import in the diff route is
folded into one at the top.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(routing): fall back to the built-in judge when the external router cannot answer
A fully-OSS deployment configures the judge through the top-level `llm:`
block, has no `routing:` block, and keeps a `kind: databricks` provider for
inference. The bootstrap then auto-builds an external routing client pointed
at that workspace's `/ai-gateway/routing/v1`, the workspace never had the
routing API enabled, and every `routes:select` came back HTTP 404 — so the
session showed "Routing unavailable" while the judge it configured was never
asked. Smart Routing was effectively off for the whole OSS flow.
Route through both backends instead of one: `route_with_fallback` still
prefers the external router wherever it can serve (the Databricks posture is
unchanged), and asks the judge behind it when that call fails or declines.
The decision records `oss-llm`, so the chip says who answered. Every routing
surface goes through it — session/create routing, turn routing, the native
route-turn hook, and subagent spawns.
The 404 whose body says routes:select is not enabled is account-level
configuration rather than an outage, so the client latches it and skips the
request from then on; `/v1/info` stops advertising a router that can only
decline. Nothing is persisted — a restart re-probes.
Choosing BETWEEN native panes still needs the workspace router's menu, so a
judge-only deployment keeps the default pane on a top-level Smart Routing
create and routes just its model, with the reason on the chip, rather than
declining into a session with no terminal.
Fail-open is unchanged throughout: a routing failure never blocks a turn, a
spawn, or a create, and never claims the route-once label.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Isaac
* fix(web): require the external router for the native-pane Smart Routing row
On a deployment whose only smart router is the built-in OSS LLM judge, the
new-session picker still offered the top-level Smart Routing row — the one
that launches a native CLI pane with the router choosing BOTH the harness and
the model. Choosing which pane launches is the external AI-Gateway (task_v1)
router's job; the judge routes a model inside an already-chosen harness, so
that row had nothing behind it and the session would fail at launch.
Gate the row on `smart_routing_sources.external`. A judge-only server now
reports its own cause ("needs the workspace AI gateway router on this
server") instead of blaming the host's CLIs. Since the row runs on the
external router alone, the built-in judge also stops covering for an arm the
host keeps off the gateway — `not-gateway-backed` fires again there.
Two neighbouring surfaces are deliberately untouched:
- Per-harness Smart Routing (the Model row's `__smart__` sentinel, router
picks the model per turn) still takes either source, so it stays on a
judge-only deployment.
- A bundle agent's routed brain (Polly / Debby's "auto" harness override)
still takes either source too — the judge picks that harness as well as its
model — and has a test pinning it against a judge-only server.
`smart_routing_sources` is absent on an older server, and `resolveServerInfo`
already degrades that to both sources from `smart_routing_enabled`, so such a
server keeps the row exactly as it had it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): keep a named-worker spawn on its own harness
A Smart Routing parent forced EVERY child create onto the "auto" harness
sentinel, including a spawn that named a worker (polly's `pi`,
`claude_code`, `codex`). The child's first message then routed against
the whole multi-harness catalog, so a pi worker came back with a codex
verdict stamped "applied" while the runner respawned its pane from pi
onto codex mid-flight — and a native worker lost the terminal labels the
forced-auto branch skips.
A named sub-agent and an explicit spawn `harness_override` both decide
the CLI the child boots on, so neither is handed the sentinel now. The
child-routing call also reads its family off the CHILD rather than the
parent: parent-derived confinement offered a pi worker the brain's claude
family, and dropped confinement entirely under an auto brain. Candidates
are the child's own harness, so the verdict is an in-family pick or an
honest decline.
Finally, a verdict naming a harness the call never offered is dropped
rather than applied (worker-name spellings still resolve), so no routing
path can pin another family onto a pane already running.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Isaac
* fix(runner): report a routed session's real harness, not its spec's
The runner derived a session's harness from its cached spec alone, so a
session Smart Routing moved off that harness still read as the one it was
declared with. On a routed child of a bundle agent that flipped the
native-vs-SDK verdict: polly's `claude_code` / `codex` workers declare
native harnesses but ran the SDK `codex` the router picked, so the
SDK turn's stream-end skipped the completion push (it belongs to a native
path that never runs) and its status events were suppressed. The parent's
inbox only ever received the `pi` sibling — the one whose declared
harness was already non-native — and it waited on the other two forever.
The forwarded `harness_override` is recorded per session and wins over
the spec, so every nativeness check answers for the process that is
actually running.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Isaac
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
The non-closing duplicate comment ended with "Leaving it open for a
maintainer to confirm", which parks the issue in a queue nobody is
watching. The reporter is the one person who can settle it immediately:
they know whether the linked issue covers their case.
Both the `duplicate` (closure disabled) and `similar` comments now ask
the reporter to take a look and close their own issue if it matches,
with an explicit path for when it doesn't. The `similar` copy stays
softer — a loose match is a weaker basis for that ask.
Rendering the new copy surfaced a pre-existing grammar bug: the plural
branch produced "these already covers this". Replaced with a phrase that
agrees in number, plus a regression test.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* dev/resolve-agent: resolve a reproduced bug (review-or-fix) and prove it
Adds the step after repro-agent: given a pointer to a completed repro run — a
local session link or a CI run URL (--ci-link) — resolve-agent recovers the
reproduction (verdict, per-facet breakdown, journey, the authored e2e test) and
drives the bug to resolution.
Two paths, decided by whether an open PR already fixes the bug:
- Review path: check out the existing PR, run the repro test against it
(pass = it fixes the bug; fail = it doesn't), review the diff, and comment
findings on that PR — no competing PR opened.
- Author path: audit the repro test against the unfixed tree so it fails on real
buggy behavior, root-cause, fix, add targeted tests at the changed layer, and
prove every live facet goes fail->pass.
Robustness on the author path: hostile-env rerun of env-default tests; an
independent cross-vendor review (a codex-native reviewer child on its own diff,
fed a recurring-pitfalls checklist) before opening the PR, reusing the server +
runner it already runs on. Opens a ready-for-review PR; does not merge.
--skip-push commits locally without pushing.
dev/resolve.py mirrors dev/repro.py; tests/dev/test_resolve.py unit-tests the
driver helpers.
Co-authored-by: Isaac
* dev/resolve-agent: address PR review — base off origin/main, stricter ci-link parse, honest guard comment
Review feedback on #4127:
- Base the fix worktree on the latest origin/main, not this checkout's HEAD.
Running the driver from a feature branch would otherwise drag unrelated
commits into the fix worktree and contaminate the PR/review. Adds
_resolve_base_ref() (fetch origin/main, fall back to local main, then HEAD).
- Confirm before creating the worktree, so answering "no" no longer leaves an
orphaned fix/<slug> worktree + branch on disk.
- Parse the --ci-link URL structurally (scheme + github.com host + anchored
path) instead of an unanchored substring regex, so a string that merely
contains the run path (or a different host) is rejected. Adds rejection tests.
- Soften the headless_subagent_purpose_guard comment in config.yaml: it only
inspects sys_session_send, not the sys_session_create that launches the
reviewer child, so it does not itself constrain that child — spawn_bounds caps
the fan-out and the reviewer's read-only behavior rests on its prompt + the
codex bundle's guardrails.
- Fix two inaccurate inline comments (worktree base, absolute-agent-path
rationale) to match the actual flow.
Co-authored-by: Isaac
* dev/resolve-agent: recover the pasted test from CI logs (repro-agent #4207)
repro-agent now pastes the complete verbatim e2e test source into its final
message before the JSON handoff. The CI job log echoes that message untruncated,
so on the --ci-link path the log itself now carries the full test body — prefer
reading it from the inline block there, with gh run download as the fallback.
(A live --session transcript is still truncated, so the disk read off the repro
session's workspace stays the robust path locally.)
Co-authored-by: Isaac
`omni host stop` pre-checks `GET /v1/sessions` so it never terminates a
daemon out from under live sessions. That API is one of the slowest on
managed, so the pre-check times out on otherwise healthy hosts and the
command fails with a bare `session list failed: ReadTimeout`.
`--force` already skips the pre-check and stops the daemon anyway, but
the failure never said so, leaving the daemon looking unstoppable. Name
both escape hatches in the error instead.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* docs(repro-agent): keep the journey user-observable, not a mechanism trace
The repro-agent was conflating the reproduction *journey* with the bug's
root-cause analysis: when a report named code paths, it verified those paths
(code traces / unit tests) instead of driving the observable user journey, and
packed the failure mechanism into the one-line `journey` field.
Sharpen the spec so the journey is strictly an ordered list of user actions
ending in a user-visible failure:
- Step 1: define the journey as concrete numbered user actions; a named code
path is a hypothesis to confirm as a facet, not the thing to verify. When a
report has no clear "Steps to reproduce", derive the journey rather than
adopting the root-cause analysis; if no reproducible user journey exists,
stop with needs_more_info.
- `journey` output field: the ordered user actions compacted to one line, with
the internal mechanism kept out (it belongs in facets/evidence).
- Also require pasting the authored e2e test source inline, immediately before
the JSON handoff block, so the reproduction test is visible when browsing the
session.
Co-authored-by: Isaac
* docs(repro-agent): require the inline test be complete, not elided
The agent pasted the test with the body replaced by a `# ... (see full file)`
placeholder, defeating the point of showing it inline. Spell out that the inline
block must be the whole file byte-for-byte, with no truncation, summary, or
placeholder.
Co-authored-by: Isaac
* docs(repro-agent): cover passive/time/system triggers as journey steps
The journey rules leaned on active user actions (click, type, send), so for
lifecycle/timeout bugs (e.g. an idle-timeout teardown hang) the agent had no
"action" to anchor on and fell back to dumping the mechanism trace into the
journey field. Spell out that passive triggers — waiting through a timeout, a
runner shutdown, a network drop — are journey steps, written as the observable
condition, not the code they run.
Co-authored-by: Isaac
2026-08-06 14:58:05 +08:00
999 changed files with 100950 additions and 19574 deletions
@@ -5,6 +5,297 @@ generated at release time from each PR's `## Changelog` section, tagged by the
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
website under `/releases`.
## [v0.10.0] — 2026-08-19
- [Bug fix / Test/CI] Host daemons now honor standard proxy environment variables without forwarding (#1029)
- [UI / Feature] Route host- and session-scoped server requests to the replica holding the host's tunnel, and signal `wrong_replica` (HTTP 400 / WS 4400) so clients re-address on a miss. (#2037)
- [Bug fix] Keep `serve-mcp` responsive to pings and additional requests during slow tool calls. (#2813)
- [UI / Feature / Docs / Test/CI] White-label the web UI from server config with custom names, headings, safe logo assets, favicon, and optional Omnigent attribution. (#2857)
- [Bug fix] A transient server hiccup no longer pins a session to the wrong working directory for the rest of the conversation. (#3017)
- [Bug fix] Claude SDK agents now discover large MCP tool definitions on demand instead of loading every schema up front. (#3134)
- [Bug fix / Docs] Sub-agents no longer inherit their parent's bundle directory (skills, local tools); resolvable children use their own, while unresolvable children never fall back to the parent's (#3567)
- [UI / Bug fix] Native-harness sessions no longer leave a stale duplicate of your message (or of the assistant's reply) pinned to the bottom of the web transcript. (#3595)
- [Feature] GitHub policy blocks tag pushes (`--tags` / `--follow-tags` / `refs/tags/` refspecs) by default; opt out with `deny_tag_push: false`. (#3620)
- [Bug fix] Server URLs and log paths in `omni host status` are now proper clickable links instead of text the terminal has to guess at (#3862)
- [UI / Bug fix / Feature] agy sessions now mirror tool calls and sub-agents into the web UI, and no longer duplicate or truncate replies (#3890)
- [Bug fix] `force_sandbox` (and any declared `os_env.sandbox`) now actually applies to a Claude Code native session's file/shell tools, not just the terminal process (#3910)
- [Bug fix] Native sessions no longer fail with "terminal failed to start" when the host daemon was launched from a directory that has since been deleted (#3974)
- [UI / Feature] The server can offer several sandbox providers at once (`sandbox.providers`), and the new-session picker lists one option per provider (#4006)
- [Bug fix / Feature / Chore] Switching between conversations is instant — background conversations stay connected, so returning to one shows messages that arrived while you were away (#4113)
- [Bug fix] `omnigent pi` now uses each model's real context window and output limit, instead of compacting early and truncating long replies on high-context models. (#4178)
- [Feature] Route a host's tunnel, its runners, and its session traffic to one replica so multi-replica deployments keep host-scoped requests sticky. (#4185)
- [Bug fix] A custom agent that declares its own provider auth now launches on codex-native instead of stalling on the Codex sign-in screen. (#4208)
- [Bug fix] `omnigent server --host 0.0.0.0` with `OMNIGENT_LOCAL_SINGLE_USER=1` no longer 401s every request and 403s the host tunnel; the single-user marker is honored on network-exposed binds, with a warning that the server serves unauthenticated requests (#4224)
- [UI / Bug fix] New chats in a project with a default base branch now fork a fresh branch off that default instead of reopening your last-used worktree (#4229)
- [UI] New-chat landing header uses tighter Otto sizing and responsive headline scale (#4233)
- [Bug fix] Changing effort or model mid-conversation on a Claude Code session no longer risks wedging the terminal or silently keeping the old setting. (#4250)
- [Bug fix] `omnigent chat <remote-url>` can now start a new conversation with an agent registered on the remote server (#4260)
- [UI / Bug fix] Collapsed "Worked for …" rows in a chat transcript now sit at an even spacing and draw their full-width divider (#4284)
- [UI / Bug fix] Voice dictation now inserts text at your cursor instead of appending it to the end of the composer (#4290)
- [UI / Feature] The file panel can now browse anywhere the session can reach, not just the folder it started in (#4306)
- [UI / Bug fix] Navigating to another session while a new one is still being created no longer yanks you into the new session when it finishes (#4307)
- [UI / Bug fix] New polly and debby sessions now show the same "Starting up…" spinner as claude-code, instead of a "Connecting…" row under the composer (#4312)
- [Bug fix] Duplicate-detection comments no longer ask you to close your issue when the matching issue is already closed — an already-fixed match now asks whether you're on a build with the fix, and treats a still-reproducing report as a regression. (#4313)
- [Bug fix] Kimi and Hermes sessions launch again — their version checks compared against the wrong version series and rejected every current CLI. (#4314)
- [UI / Feature] Organizations can preconfigure Omnigent server URLs through Android managed configuration, and they show up ready to tap in the app's server list. (#4315)
- [Feature] `omnigent host --background` starts the local server and registers this machine as a host without tying up a terminal. (#4317)
- [UI / Breaking] Reverts the shared-session approval-authority and message-attribution features (#2150 stack); session approvals are again available to any shared editor. (#4318)
- [UI] Administrators can preset the iOS app's server URLs with a managed app configuration, so managed users pick their organization's server instead of typing it (#4319)
- [Bug fix] Fixed a bug where an archived session (or an archived sub-agent) could be gated as if it had spent nothing, letting a tool call proceed over its actual budget. (#4320)
- [Feature] `omnigent start` starts the local server and registers this machine as a host — the on switch to go with `omnigent stop`. (#4321)
- [Bug fix / Test/CI] Fix `omnidev` restart loop on Linux caused by non-mutating file access events (#4330)
- [Feature / Docs] Issue triage now explains its impact assessment and priority in one bot comment instead of adding severity labels. (#4334)
- [UI / Bug fix] Opening the left sidebar no longer squeezes the chat below its minimum width — the browser/workspace panel yields instead and restores its width when the sidebar collapses. (#4337)
- [Bug fix] Pi sessions on a Databricks workspace no longer hang when the workspace model list is unavailable — non-Claude models are routed by family, and a model that truly can't be served now says so instead of never replying (#4339)
- [Chore] Files in the viewer load faster — workspace-file reads are now gzipped, cutting a 1 MB text file from ~2.3 s to ~1.3 s (#4341)
- [Bug fix / Chore] A claude-native session no longer shows "Working…" forever when Claude exits (#4344)
- [UI] The sidebar "Needs response" badge now uses the brand accent color (pink by default), matching the unread indicator. (#4346)
- [UI] Refreshed modal styling — larger rounded corners, softer drop shadow, and roomier padding (#4347)
- [Bug fix] Pi sessions on a proxy that exposes both Anthropic and OpenAI surfaces now send each model to the surface its family speaks, instead of routing everything through Anthropic and hanging (#4348)
- [Bug fix] Session snapshots no longer fail when a session contains a malformed legacy agent id. (#4350)
- [Bug fix] A session whose message the runner refuses now reports the failure and its reason instead of appearing to finish successfully. (#4354)
- [UI / Feature] Hover the collapsed sidebar toggle to peek the conversation list without pinning it open. (#4355)
- [Bug fix] Generic-ACP / Goose / Qwen turns that fail now report the exception type instead of a blank "inner executor error: " with no detail. (#4362)
- [UI / Bug fix] Messages sent from the chat UI no longer stop reaching the agent after a dropped network request (#4366)
- [UI / Chore] The Files panel is now split into separate **Files** (folder tree) and **Changes** (changed files) tabs (#4367)
- [UI / Feature] Chat messages now show their timestamp beside the Copy/Fork actions. (#4372)
- [Bug fix] A conversation link copied from your browser now works wherever a server URL is expected, instead of failing later with an opaque "Method Not Allowed" crash (#4374)
- [UI / Bug fix / Test/CI] Clicking a sidebar session that needs a response no longer runs its title under the "Needs response" tag, and the Inbox count badge now matches the sidebar's pink accent (#4375)
- [UI / Bug fix] Maximized workspace panel no longer shows chat content through a transparent background in dark mode. (#4376)
- [Bug fix] Agents now inherit your ssh-agent, so git-over-SSH and SSH-cert-authenticated tooling work in agent shells and terminals (#4377)
- [Bug fix] The sidebar remembers your session filter across reloads instead of resetting to "All sessions" (#4381)
- [Feature / Docs / Test/CI] Blaxel is now available as a sandbox provider for CLI and managed-host deployments. (#4383)
- [UI] The Chat/Terminal switcher is now a segmented toggle — both views are visible at a glance and switching takes one click (#4385)
- [Bug fix / Feature] `omnigent run --server local` runs against a local server, overriding any configured server default — and the no-AGENT `omnigent run --server ""` no longer fails with `Agent path not found: https:` (#4387)
- [UI / Bug fix] Terminal-first sessions return to chat automatically when the runner stops or disconnects, instead of stranding on an empty "No terminals available" terminal view (#4388)
- [Bug fix] The `build-omnigent` skill is available again in native `omnigent claude` and `omnigent codex` sessions (#4391)
- [Bug fix] A custom ACP agent can declare the environment variables it authenticates with via `env_passthrough`, and a stalled ACP handshake now reports which call timed out instead of failing with an empty message. (#4392)
- [Bug fix / Feature] The Copilot harness now authenticates with your existing `gh auth login` session, and a GitHub Enterprise host can be set via `omnigent setup` (#4396)
- [Bug fix] MCP server configs can now use `${VAR}` placeholders in the `url` field, not just in `headers` — so a config can be committed to version control without hardcoding the endpoint. (#4398)
- [Bug fix] `kimi-native` sub-agents honor `executor.config.yolo: true` (launching `kimi --yolo`) and `antigravity-native` sub-agents honor `permission_mode: bypassPermissions`, so server-spawned workers no longer stall on interactive approval prompts. (#4401)
- [Bug fix] Resuming a claude-native session no longer drops a message sent right after the session starts. (#4403)
- [Bug fix] A sub-agent session no longer logs a spurious "did not resolve in the parent spec" warning on every turn. (#4435)
- [UI / Bug fix] Bulk-select sessions and move them to a project in one action via the new folder icon in the selection bar. (#4452)
- [UI] Codex's bypass-approvals option now matches Claude's clean permission UX — no more red warning banners (#4467)
- [Bug fix] Compaction snapshots no longer store raw image data, which cuts the size of newly written compacted conversation rows substantially. (#4470)
- [UI / Bug fix] The new-session workspace picker now navigates to `~/…` paths and shows a clear error when a typed path doesn't exist (#4480)
- [UI / Feature] Harness launch failures now show a clear title, cause, and suggested fix instead of a raw error code and truncated log tail. (#4485)
- [UI / Feature] Sub-agents are now auto-assigned readable structured names (e.g. `researcher-1`) and a task-derived display label in the Agents panel (#4489)
- [UI] Restored the down chevrons on the new-session composer's chips and made every dropdown trigger show a pointer cursor (#4493)
- [UI / Bug fix] Modal dialogs and the workspace "Open new" menu no longer render behind the embedded browser pane (#4500)
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4501)
- [Bug fix] Session search no longer hangs on "Searching…" — content search is now backed by a trigram index and bounded by a timeout. (#4502)
- [Bug fix / Test/CI] Fixes an HTTP client resource leak when OpenAI Agents SDK executors shut down. (#4508)
- [Bug fix] Sessions ride out brief runner-tunnel drops (laptop sleep-wake, ingress recycles) without flashing Failed or truncating the streaming reply. (#4516)
- [Bug fix] `omnigent` no longer crashes on startup when your shell sets a SOCKS proxy (e.g. `ALL_PROXY=socks5://…`) and the `httpx[socks]` extra isn't installed (#4517)
- [UI / Bug fix] Attaching an unsupported file in a new chat now tells you why up front and keeps your message instead of losing it (#4519)
- [Bug fix] `omni` now works on machines with an HTTP proxy configured, and reports an unreachable server as a clear error instead of a crash. (#4520)
- [Bug fix] Sessions that outlive their 60-minute runner bearer no longer permanently lose runner→server auth when the token re-mint is rejected — the runner now falls back to the machine's SDK/OIDC credential. (#4521)
- [Bug fix] Harness logs now go to a file under `~/.omnigent/logs/harness/` (or the runner's log), and a failing ACP turn quotes the agent's own error output instead of dropping it. (#4523)
- [Bug fix] An `openai-agents` agent with no pinned model no longer fails with a confusing "install databricks-sdk" error when only OpenAI credentials are missing (#4526)
- [Bug fix] Claude native sessions now report per-turn token usage (`gen_ai.usage.input_tokens` / `output_tokens`) to MLflow and other OpenTelemetry backends. (#4530)
- [UI / Bug fix / Feature] Move a running session to another machine from the host badge in the composer. (#4531)
- [Bug fix] Upgrading omnigent in place no longer breaks harness launches on already-running runners (#4539)
- [Chore] The session-search index migration builds its Postgres indexes concurrently, so upgrades no longer block writes while the indexes build. (#4541)
- [Feature] The Android app now opens Databricks workspaces on their `/omnigent` app instead of the workspace landing page. (#4543)
- [Bug fix] `omnigent host` pointed at a local server that has exited now stops after ~5 minutes with a clear error instead of reconnecting forever. (#4544)
- [Bug fix] Runners no longer crash-loop at session start when signal-handler registration fails; they log one warning and keep working. (#4545)
- [Bug fix] Session search now returns results instead of timing out on large workspaces. (#4546)
- [UI / Bug fix] Modal buttons like Stop session and Clone now show a spinner while the action is running, instead of just greying out. (#4548)
- [UI / Bug fix] The desktop server picker moved from the window title bar to the bottom of the sidebar, fixing an overlap with the chat header on narrow windows — and Windows and Linux desktop now have it too (#4551)
- [UI] The embedded terminal connects in the background and stays connected across Chat/Terminal flips and recent-session switches, so opening it is near-instant instead of reconnecting every time. (#4552)
- [Bug fix] The Android app no longer shows the Databricks workspace navigation bar around Omnigent when connecting to a workspace-hosted server. (#4555)
- [UI / Feature] On the macOS desktop app, the sidebar header now shares the title-bar row with the window controls — the empty strip above the sidebar and the redundant wordmark row are gone, and the Collapse/Search/Settings buttons sit beside the traffic lights. (#4557)
- [Bug fix] Codex sessions on a ChatGPT-account or API-key login launch again, instead of failing with "model is not supported when using Codex with a ChatGPT account" (#4558)
- [UI] Connecting the iOS app to a Databricks workspace now opens Omnigent directly and hides the workspace navigation bar (#4559)
- [Bug fix] kiro sessions no longer fail the first message with a connection error when the kiro TUI is slow to start (#4562)
- [Bug fix] `omnigent host` now warns once and backs off when a server accepts connections but never responds, waits out (up to 120s) a slow-booting local server instead of stranding it — stopping it if it truly fails — recovers fast runner starts after a zygote crash, and no longer leaves empty log files behind. (#4563)
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4564)
- [UI / Bug fix] Deleting a session removes it from the sidebar immediately instead of waiting for the server to finish tearing it down (#4566)
- [Bug fix] Session search no longer times out on large workspaces. (#4567)
- [UI / Bug fix] Fixed iOS controls rendering under the status bar on Databricks workspace-hosted servers (#4568)
- [UI / Feature] You can now leave a session someone shared with you — pick "Leave session" from its sidebar row menu to clear it from your sidebar without asking the owner (#4571)
- [UI / Bug fix] The workspace Files panel now shows hidden files by default, and its eye icon shows whether they are visible rather than what clicking will do. (#4575)
- [Bug fix] Switching a session's agent now updates the tools its native harness can call, instead of leaving the previous agent's tools in place (#4576)
- [Bug fix] The native pane reaper no longer kills a terminal that is actively producing output when the harness status pipeline stalls; it now checks tmux's own activity clock before reaping. (#4577)
- [Bug fix] A silently stalled claude-native transcript forwarder now self-recovers within five minutes and logs exactly where it stalled, instead of freezing mirroring and session status indefinitely. (#4578)
- [Bug fix] A claude-native transcript forwarder that stops — cancelled, crashed, or returned — now always logs an attributed exit line instead of dying silently. (#4579)
- [Bug fix] A stray hook event from another Claude session can no longer silently redirect a claude-native session's transcript mirroring; session identity now changes only via SessionStart announcements. (#4580)
- [Bug fix] `omni claude` streaming, statusline, and typing during tool-running turns now respond at native speed: hook subprocesses skip the framework's eager import graph, and the blocking hook path runs as shell + a loopback `curl` relayed by the long-lived runner instead of spawning a Python interpreter per event. (#4582)
- [UI / Bug fix / Feature] Fork a sub-agent to promote it into a top-level session of its own. (#4584)
- [Bug fix] Upgrading omnigent in place no longer breaks runner launches on already-running hosts (#4587)
- [UI / Bug fix] Native-harness sessions no longer briefly drop your in-flight message bubble when an interrupt marker is reconciled at the same time. (#4591)
- [UI / Bug fix / Chore] Native assistant text now reconciles cleanly with committed transcript messages without duplicate streaming output. (#4593)
- [Bug fix] The embedded browser pane no longer lingers over the welcome screen after switching or disconnecting from a server (#4595)
- [Chore] Removed the "A new version of Omnigent is available" prompt and browser PWA install support; the desktop and mobile apps remain the installable clients. (#4617)
- [Chore] OpenTelemetry exporters and automatic instrumentors are now installed through `omnigent[tracing]` instead of the default package. (#4621)
- [Bug fix] Development builds no longer show an update reminder for the matching final release. (#4628)
- [Bug fix] `omni host` no longer prints a zygote traceback — and keeps copy-on-write runner forking — when started from a directory that contains an `omnigent` checkout (#4631)
- [UI / Bug fix] Clicking a file an agent mentions in its reply now opens it, including `path:line` citations and markdown links (#4644)
- [UI / Bug fix] Fixed long unbroken text or inline code in chat messages overflowing or getting cut off at narrow window widths. (#4651)
- [Bug fix] Fixed a duplicate assistant message that could appear after reconnecting to a Claude Code (native) session (#4656)
- [Bug fix / Chore] Resuming or forking a Claude Code session containing screenshots no longer duplicates image payloads into metadata and inflates the request past the context limit. (#4659)
- [UI / Bug fix] Archiving the current session now redirects to the home page instead of leaving you on the archived session (#4671)
- [UI / Feature] Usage page shows session costs, daily spend timeline, and breakdowns by harness and model (#4673)
- [Bug fix] Sessions whose workspace is an omnigent checkout no longer run a different omnigent than the one you installed (#4688)
- [Bug fix] `omnigent run --harness acp:<agent>` now launches the ACP agent you asked for instead of the first one configured (#4689)
- [UI / Bug fix] The desktop app now auto-selects this machine after "Run on this machine" (#4691)
- [UI / Bug fix] The managed sandbox host option (and other capability-gated UI) now appears on its own after a slow `/v1/info` probe, instead of staying hidden until a page reload. (#4694)
- [Chore] Runner-backed resource APIs now avoid redundant session reads, improving responsiveness under load. (#4695)
- [Bug fix] ACP agents now report cached-read tokens, so token usage reflects what was actually billed (#4699)
- [Bug fix] Built-in ACP agents like Grok Build now appear in `omni setup` instead of being invisible (#4700)
- [Bug fix] `omnigent run --harness acp:<slug>` now works with remote servers by resolving the slug client-side and embedding the full agent config in the spec. ACP agent settings (session_id_mode, send_model, omnigent_mcp, env_passthrough) are now preserved through embedding. (#4702)
- [Bug fix / Feature] `/model` now switches an ACP agent's model mid-conversation instead of being ignored, and keeps the chat history (#4703)
- [Bug fix] A host name set in `config.yaml` is now kept when no `host_id` is present — the id is generated instead of overwriting your chosen name. (#4708)
- [Bug fix] `omnigent resume` now lists only your own sessions, not ones shared with you (#4709)
- [UI / Bug fix] The Inbox count badge now uses the same text and background colors as the selected session item in the sidebar (#4714)
- [UI / Feature] Multi-session delete now shows a table of worktree branches you can pick to clean up (with a tri-state select-all header), instead of blocking branch cleanup behind single-session delete (#4715)
- [Bug fix] OpenCode 1.18.x installs are now accepted; the version gate no longer rejects users who installed OpenCode via its official upstream route. (#4725)
- [UI / Bug fix / Chore / Test/CI] Approval prompts now name the assistant that asked (Claude Code, Codex, Cursor, Antigravity, Kiro, Goose, Qwen Code, Hermes) instead of an internal policy id (#4735)
- [UI / Bug fix] Opening the workspace folder browser no longer flashes an "Up one level" tooltip over the listing (#4742)
- [Feature] Kubernetes sandbox runners now use Jobs with automatic restart on crash (up to 3 retries) and a liveness probe, replacing bare Pods that required manual intervention after a failure. (#4744)
- [UI / Bug fix] The chat transcript now detects a silently dead live connection and reconnects on its own — worst case 45 s, instantly on tab refocus — instead of freezing until the page is reloaded. (#4750)
- [Bug fix] Chat messages sent while the terminal's Claude composer is covered by the ctrl+r history search or a hand-opened `/model` picker now dismiss the overlay and deliver, instead of silently vanishing (#4751)
- [Bug fix] Creating a session from the web UI is faster end-to-end: the chat page opens immediately and the terminal is ready sooner — including the first session after a host restart, which no longer pays a multi-second warmup. (#4752)
- [UI / Bug fix] Answered question and plan cards stay outside the "Worked for" fold, read as settled, and survive a page reload (#4760)
- [UI / Feature] Web terminals now attach directly over loopback when the runner is on the same machine as the browser, cutting keystroke echo from ~250 ms to under 10 ms against a remote server. (#4763)
- [UI / Bug fix / Test/CI] The Sessions filter is now always visible, so session filtering is discoverable without hovering the sidebar header. (#4764)
- [Feature] New `OMNIGENT_REQUIRE_WRAPPER` env var lets operators require the CLI be run through a wrapper (e.g. `isaac omni`) and refuse direct `omni` calls (#4766)
- [UI / Bug fix / Chore / Test/CI] Chat output stays visible above a growing composer; bottom-following readers remain pinned while readers who scroll up—even just 50px—keep the same visible content. (#4767)
- [Bug fix] `omnidev --vite-port` once again starts the frontend on the requested port. (#4770)
- [Feature / Test/CI] macOS desktop app now ships an Intel (x64) build alongside Apple Silicon. (#4772)
- [UI / Feature / Docs] Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`. (#4775)
- [UI / Bug fix / Feature] In-chat errors now provide expandable diagnostics and recovery-aware Retry, copy, and dismiss actions without replaying failed input, and cancelled retry requests no longer leave stale recovery results cached. (#4787)
- [Feature / Docs] ArgoCD quick-start overlay for deploying Omnigent with the kubernetes sandbox provider (#4788)
- [Bug fix] `omnigent[antigravity]` no longer crashes with a protobuf gencode/runtime version mismatch on startup. (#4795)
- [UI / Bug fix] Server URLs in copyable connection and reconnect commands are now safely quoted. (#4817)
- [Bug fix] Resumed Codex conversations now keep the authentication provider selected in Codex configuration. (#4818)
- [Bug fix] `omnidev omnigent` commands now keep runtime state and configuration inside their development pod. (#4822)
- [Bug fix] Native Windows: harness CLIs (codex, pi, claude-sdk, antigravity) no longer hang on spawn due to missing `SYSTEMROOT`/`COMSPEC`; Windows drive-letter workspace paths (`C:\…`) are now accepted; pre-release harness CLI versions (e.g. `0.146.0-alpha.9.2`) no longer fail the version gate. (#4886)
- [UI / Feature] Background tasks that keep running after a turn ends now show as a pill above the composer instead of a "Working…" spinner (#4893)
- [UI / Bug fix] Maximizing the workspace panel in the desktop app no longer tucks its tab icons under the macOS window controls. (#4897)
- [Feature] Configured ACP agents (e.g. Devin) and installed ACP CLI harnesses (e.g. Grok Build) now appear in the web New Chat picker, like the native harnesses (#4909)
- [Bug fix] Managed codex, claude-sdk (Polly/Debby), and pi harnesses resolve their launch model from the workspace's Unity Catalog model services, so they no longer fail against a Databricks AI Gateway that has retired the legacy `databricks-*` model namespace (#4915)
- [UI / Feature] Devin is now a built-in harness — set it up in `omni setup`, launch it with `--harness devin` or from the New Chat picker, no `acp:` config needed (#4920)
- [Bug fix] `omni setup` and the New Chat picker no longer show a duplicate — or silently ignore — a built-in ACP harness you configured yourself under the same name; your own command wins. (#4927)
- [UI] Chat error banners are now a compact centered pill with inline Retry, matching the design prototype (#4931)
- [UI / Feature] The chat header now shows the conversation's name, its project folder, and the sub-agent path, and title bars are a consistent 48px. (#4940)
## [v0.9.0] — 2026-08-11
- [UI / Bug fix] Recent servers remain one-click connectable and now include a separate copy action. (#2555)
- [Feature] New `nimble_extract` and `nimble_research` builtins: pull structured web data through a (#3117)
- [Bug fix] Claude SDK sessions no longer inline base64 image/document tool results into replayed history, guarding against context overflow on resume (#3120)
- [Bug fix] Session metadata now exposes the last persisted activity time so orchestrators can detect running sessions that have stopped advancing. (#3279)
- [Feature / Docs] Agent YAML now supports `sandbox.type: auto` to explicitly select the platform-default sandbox. (#3339)
- [Feature / Breaking] Managed runner Pods are now labelled with the agent they are running, so admission policies and Pod selectors can target a single agent's sandboxes. **Breaking:** session create and patch now reject client-supplied `omnigent.sandbox.*` labels, a namespace reserved for server-internal sandbox lifecycle state. (#3361)
- [Bug fix] The GitHub and working-directory policies now gate commands wrapped in `sudo -u`, `env -i`, `command -p`, `time -p` and `exec -a` — including bundled short options like `sudo -nu root` — instead of letting them through (#3559)
- [Feature] GitHub policy blocks force-push (`--force` / `-f` / `--force-with-lease`) by default; opt out with `deny_force_push: false`. (#3570)
- [Bug fix] `omni server start` works again as a deprecated alias for `omni server --background`, fixing "Start locally" on desktop clients older than v0.7.0. (#3578) (#3597)
- [Bug fix] Codex sessions opened from the desktop no longer time out while waiting for a hidden project-trust prompt. (#3709)
- [UI / Bug fix] Archiving a session no longer waits for its runner to stop — the row leaves the sidebar immediately while the server stops the session in the background (#3783)
- [UI / Feature] Completed turns in the chat view fold their working steps behind a "Worked for Xs" row, so the final answer is where reading starts (#3786)
- [UI / Bug fix] Forking a session in a project now files the clone into the same project, and it appears in that folder immediately (#3793)
- [Bug fix] Project creation now works on Databricks Apps deployments — the entrypoint wires the project store so the Projects API is mounted. (#3866)
- [Bug fix] Antigravity workers now wait for model initialization before starting a fresh conversation. (#3878)
- [Bug fix] `omnidev` no longer fails to start when a global `UV_PYTHON` points at an unsupported interpreter (#3883)
- [Chore] DELETE THIS WHOLE SECTION — internal runtime tuning, no user-facing change. (#3901)
- [Feature] DELETE THIS WHOLE SECTION — internal runner infrastructure, no user-facing change. (#3921)
- [Bug fix / Test/CI] The desktop app's version now tracks the release version automatically (previous releases shipped the desktop shell still reporting 0.6.0) (#4005)
- [Feature] Database observability integrations can identify semantic FileStore and ConversationStore queries. (#4007)
- [Bug fix] Long context compaction no longer fails with a 240s idle-watchdog timeout on near-full sessions (#4013)
- [Bug fix] Managed-host wake now reports launch progress ("Starting host") instead of a frozen "Provisioning sandbox" band. (#4016)
- [Bug fix / Feature] `omnigent run --profile <name>` authenticates a remote `--server` with a named Databricks profile (enables headless service-principal access to a deployed app) (#4017)
- [UI] Text and borders now use the Zinc neutral palette. (#4019)
- [UI / Chore] The sidebar and side panel now sit flush against the window edges, with the background gradient moved onto the sidebar. (#4020)
- [UI] Tightened body and chat text to the design's 13/18 type scale. (#4021)
- [Bug fix] Fixed a bug where a transient IP ACL block mid-session could permanently brick the runner's token factory, requiring a manual restart to recover. (#4024)
- [Bug fix] `omnigent host` now survives a dropped VPN — a 401/403 on an already-connected host retries and auto-reconnects instead of exiting (#4025)
- [UI / Bug fix] Error messages that mention environment variables like `$LLM_API_KEY` no longer render as garbled math (#4026)
- [Bug fix] Native Codex and Claude provider credentials are kept out of child process arguments. (#4030)
-`omni host status` is significantly faster when stale daemon records have accumulated. (#4031)
- [Feature / Docs / Test/CI] New issues now receive duplicate guidance; high-confidence duplicates are labeled and linked, with automatic closure available behind a default-off repository variable. (#4037)
- [UI / Bug fix] Switching between two terminal-view sessions no longer shows the previous session's stale terminal history (#4043)
- [UI / Bug fix] The landing screen headline has a new look, and the sidebar is no longer transparent over the conversation on narrow screens. (#4052)
- [UI / Bug fix / Feature] Sessions can now be filtered by All, My, Shared, or Archived from the Sessions heading, and the Projects menu offers both Expand all and Collapse all. (#4055)
- [Feature / Chore / Test/CI] Database operations now expose stable semantic query names for tracing and logging. (#4059)
- [Bug fix] Approving a plan from the web UI now works for Claude Code sessions, including "Yes, and use auto mode" (#4067)
- [Bug fix] The "Starting MCP servers" line in Codex sessions now clears as soon as the agent starts working, instead of lingering for the whole first turn (#4071)
- [Feature] Boxlite sandboxes support a configurable disk size via `sandbox.boxlite.disk_size_gb` (#4072)
- [Bug fix] `brew install omnigent` works again, and installs prebuilt wheels instead of (#4080)
- [Bug fix] Claude sessions routed through the Databricks AI gateway now send the `x-databricks-use-coding-agent-mode` header the gateway requires (#4082)
- [Feature / Docs / Test/CI] Comment `/reopen` on a closed pull request to reopen it yourself (#4084)
- [UI / Bug fix] A disconnected host now always shows its name with a red dot in the composer, and clicking it opens the reconnect instructions (#4090)
- [UI / Bug fix / Test/CI] Reasoning indicators now stay stable during active turns instead of switching between thinking and elapsed-time labels. (#4091)
- [Bug fix] Fix host tunnel disconnecting with "ping timeout" when a harness CLI probe hangs during the readiness refresh (#4092)
- [UI / Bug fix] The composer no longer briefly shows the previous session's model when you switch between Codex and Claude Code sessions (#4093)
- [UI / Bug fix] The "new session in project" composer now picks up a project's edited default settings immediately, without having to click away and back. (#4097)
- [Bug fix] Pi and Pi-native no longer fail with a 404 when a model ID like \`system.ai.claude-opus-5[1m]\` is configured against the Databricks AI Gateway. (#4105)
- [UI / Bug fix] [UI] Chat messages no longer crowd the conversation tick rail on narrow layouts (#4106)
- [UI / Feature] The harness picker now leads with the fully supported harnesses, Claude Code and Codex, and promotes any harness you've launched before out of the "More" group. (#4107)
- [UI / Feature] Adds an "Execution logs" debug column to the conversation page when \`?debug=1\` is in the URL, letting developers inspect raw session items for the main thread and any subagents. (#4109)
- [UI / Feature] Adds a live SSE event viewer to the execution logs debug panel (`?debug=1`) — click the **SSE** tab to see raw stream events as they arrive from the server. (#4111)
- [Feature] Issue rankings now use a deterministic, explainable severity and component scoring model. (#4117)
- [Feature] Issue-priority rankings can now be generated as a paused, review-first Databricks job. (#4118)
- [Feature] Issue prioritization can safely propose and apply severity, priority, and component labels without overwriting maintainer judgment. (#4119)
- [Feature] Dashboards can query the latest issue-priority ranking through one stable Unity Catalog view. (#4120)
- [UI / Bug fix] Inline code and other small text no longer render undersized in the web UI (#4122)
- [UI] Assistant message actions now stay hidden until you hover the response (#4123)
- [UI / Bug fix] Switching conversations shows a loading spinner again instead of painting a (#4124)
- [UI / Bug fix] Shell tabs now persist per session — opening a shell, switching sessions, and coming back keeps the tab open (#4125)
- [Feature] Add `dev/resolve-agent`: resolve a repro-agent reproduction by reviewing an (#4127)
- [Bug fix] Claude Code sessions with a background task running no longer queue new messages — they start a fresh turn immediately. (#4132)
- [UI] The composer's send button is now a rounded square with a full-size up arrow, and the home-page composer starts at a single row. (#4134)
- [Bug fix] `omnigent codex` now keeps the terminal's session link and the exit `--resume` (#4138)
- [UI] The harness selector in the composer is now a single split button — hovering either the harness name or the settings gear highlights the whole control. (#4142)
- [UI / Chore] Interface font size now scales captions, descriptions, and menu subtitles along with body text (#4150)
- [Bug fix] Policy-evaluation failures now report the underlying cause instead of an opaque `502` page. (#4154)
- [Bug fix] A managed sandbox whose host process crashes now restarts it in place, keeping (#4155)
- [UI / Bug fix] Steering a native session mid-tool-use no longer shows a raw "[Request interrupted by user]" line and a blank bubble — the interruption renders as a muted marker and your attachments stay on your message. (#4160)
- [UI / Bug fix] The chat composer no longer pushes the conversation around when you add newlines with Shift+Enter (#4161)
- [UI / Bug fix] Unarchiving a session from Settings now opens it, instead of leaving you on the settings page. (#4171)
- [UI / Bug fix] Scheduled /loop iterations in claude-native sessions now render as separate turns with their own "Worked for" folds that stay closed between ticks (#4174)
- [Bug fix] Runner tunnel HTTP 401 errors now suggest stopping stale host instances with `omnigent stop`. (#4175)
- [UI / Bug fix] [UI] New sessions open right away instead of waiting for the runner to start (#4183)
- [UI / Bug fix] Conversation images load in parallel, stay cached between visits, and no longer (#4187)
- [UI / Bug fix] The chat view no longer loses its working indicator mid-session on Claude Code sessions, and says when the agent is parked on a prompt. (#4195)
- [UI / Bug fix] The sidebar's Pinned and Projects sections now stay put when you switch the (#4200)
- [Bug fix] Archiving a session now also unpins it, so it won't reappear pinned when unarchived (#4202)
- [UI / Bug fix] Scrolling back through a conversation no longer fights you when older messages load (#4204)
- [UI / Feature] Projects can set a default base branch that pre-fills when starting a new worktree session (#4205)
- [UI] Composer footers no longer show a shaded tray behind the status chips (#4215)
- [Bug fix] `omni host stop` now tells you to retry with `--force` when the session-list pre-check times out (#4216)
- [UI / Feature] Shared elevation shadows for composers, menus, cards, and tooltips (#4218)
- [UI] Chat header icon controls are smaller (24px) and more evenly spaced (#4219)
- [Feature / Docs] Issue prioritization can now grade maintainer issues and refresh rankings with short-lived GitHub App credentials. (#4221)
- [UI] Sidebar uses tighter padding and gaps around the header, nav, and session list (#4222)
- [Feature] Duplicate-detection comments now ask the reporter to close their own issue when (#4223)
- [UI] New Chat composer footer selectors no longer show chevron icons (#4225)
- [UI] Dropdowns and menus use one consistent popup style across the app (#4228)
- [Bug fix] Fix a spurious "runner didn't come online in time" failure when starting a session in a large git workspace, by running `git status` off the runner's event loop. (#4259)
- [Bug fix] Fixed a hermes-native session losing an assistant turn's tool call or prose when a mirror post failed partway through a message row (#4261)
- [UI / Bug fix] Sending a message now works immediately while an agent's background tasks are still running, instead of queueing behind a "Steer" button, and the sidebar no longer shows a session as busy for background work alone (#4266)
- [UI / Bug fix] Claude Code / Codex / OpenCode sub-agent sessions now show the product name instead of the (#4267)
- [UI / Bug fix] Renaming a session in the sidebar no longer flashes the previous name as the edit field closes (#4277)
- [UI] New-session and chat surfaces now use consistent navigation, composer, and interaction styling. (#4288)
- [UI / Bug fix] Opening a session no longer keeps loading older messages and shifting the transcript while you read (#4291)
- [Bug fix] A runner disconnecting no longer marks finished sub-agents as failed — the Agents tab keeps their "Done" state and shows an interrupted session as a recoverable "Disconnected" instead of a red "Failed". (#4293)
- [Bug fix] Runner errors that tell you to check the logs now name the exact log file to open (#4295)
- [Bug fix] Signing in to Databricks-hosted deployments on Android now happens in the app (#4296)
- [UI / Breaking] Reverted the shared-session approval-authority and message-attribution features. **Breaking:** session approvals are once again available to any shared editor, not just the session owner. (#4318)
- [UI] The Chat/Terminal switcher is now a segmented toggle — both views are visible at a glance and switching takes one click. (#4385)
- [Bug fix] claude-native routing now counts managed-settings AI Gateway backing, so gateway-backed claude-native sessions route correctly. (#4491)
- [Bug fix] The runner preserves CLAUDE_CODE_USE_GATEWAY / ENABLE_TOOL_SEARCH, keeping tool search working for gateway-backed Claude sessions. (#4553)
- [Bug fix] Codex sessions on a ChatGPT-account or API-key login launch again, instead of failing with "model is not supported when using Codex with a ChatGPT account". (#4558)
## [v0.8.2] — 2026-08-04
- [Bug fix] Claude sessions routed through the Databricks AI gateway now send the `x-databricks-use-coding-agent-mode` header the gateway requires (#4086)
## [v0.8.1] — 2026-08-03
- [UI] Reverted the v0.8.0 "Chat/Terminal switcher in the header" change; the
│ └── README.md injection); NOT a server deploy target.
│
@@ -262,13 +265,10 @@ omnigent run path/to/agent.yaml --server https://your-host
Don't want a laptop to be the host? Run the host in a cloud sandbox instead.
**From the CLI (Modal, Daytona, Islo, or E2B).** Install the provider extra when
needed (`pip install 'omnigent[modal]'`, `'omnigent[daytona]'`, or
`'omnigent[e2b]'`; Islo uses the built-in HTTP client), authenticate
(`modal token new`, `DAYTONA_API_KEY`, `ISLO_API_KEY`, or `E2B_API_KEY`), then:
**From the CLI (Modal, Daytona, Blaxel, Islo, or E2B).** Install the provider extra when needed (`pip install 'omnigent[modal]'`, `'omnigent[daytona]'`, `'omnigent[blaxel]'`, or `'omnigent[e2b]'`; Islo uses the built-in HTTP client). Authenticate with `modal token new`, `DAYTONA_API_KEY`, Blaxel's `BL_WORKSPACE` and `BL_API_KEY`, `ISLO_API_KEY`, or `E2B_API_KEY`. Then run:
`ISLO_BASE_URL`); E2B reads `E2B_API_KEY` from the server environment.
Daytona reads `DAYTONA_API_KEY`. Blaxel reads `BL_WORKSPACE` and `BL_API_KEY`. Islo reads `ISLO_API_KEY` and optional`ISLO_BASE_URL`. E2B reads `E2B_API_KEY` from the server environment.
Each sandbox authenticates back with a server-minted, per-launch token, so
no user credentials ever enter the sandbox.
**The host image.** Sandboxes boot from the official prebaked host image
(`ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI from the `host`
target of [`docker/Dockerfile`](docker/Dockerfile)), so the host starts in
seconds instead of installing Omnigent at boot. The image ships the
coding-harness CLIs (`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run
in the sandbox with nothing extra to install. To run sandboxes from your own
image instead (a fork, or extra tooling baked in), build the same `host`
target and point the config at it:
**The host image.** Most sandboxes boot from the official prebaked host image (`ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI from the `host` target of [`docker/Dockerfile`](docker/Dockerfile)), so the host starts in seconds instead of installing Omnigent at boot. The image ships the coding-harness CLIs (`claude`, `codex`, `pi`, `kiro-cli`). Blaxel uses `blaxel/omnigent-host:latest`, which combines this host runtime with Blaxel's required `sandbox-api`. E2B uses its provider template. To use a custom image instead, build the same `host` target and point the provider config at it:
```bash
docker build -f docker/Dockerfile --target host \
@@ -325,11 +317,7 @@ sandbox:
image: docker.io/<you>/omnigent-host:latest
```
For private registries, set `OMNIGENT_MODAL_REGISTRY_SECRET` on the server
to the name of a Modal secret holding `REGISTRY_USERNAME` /
`REGISTRY_PASSWORD`; for CLI-launched sandboxes, `OMNIGENT_MODAL_HOST_IMAGE`
(or `OMNIGENT_DAYTONA_HOST_IMAGE` / `OMNIGENT_ISLO_HOST_IMAGE`) overrides the
image ref.
For private registries, set `OMNIGENT_MODAL_REGISTRY_SECRET` on the server to the name of a Modal secret holding `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`. For CLI-launched sandboxes, `OMNIGENT_MODAL_HOST_IMAGE`, `OMNIGENT_DAYTONA_HOST_IMAGE`, `OMNIGENT_BLAXEL_HOST_IMAGE`, or `OMNIGENT_ISLO_HOST_IMAGE` overrides the image.
**LLM credentials for managed sessions.** A fresh sandbox has no API keys.
Park your provider credentials in a [Modal secret](https://modal.com/secrets)
@@ -357,9 +345,7 @@ sandbox:
secrets: [omnigent-llm]
```
For Daytona and Islo, list server environment variable names under
`sandbox.daytona.env` or `sandbox.islo.env`; the launcher copies the current
server env values into each sandbox:
For Daytona, Blaxel, and Islo, list server environment variable names under`sandbox.daytona.env`, `sandbox.blaxel.env`, or `sandbox.islo.env`. The launcher copies the current server values into each sandbox:
```yaml
sandbox:
@@ -384,11 +370,7 @@ a Modal secret (GitLab: add `GIT_USERNAME=oauth2`). The host image's git
credential helper picks it up for the clone and for the agent's later
fetch/push.
The full Modal guide (CLI sandboxes, custom images, LLM and git credentials,
troubleshooting) lives at [`modal/README.md`](modal/README.md); the Daytona
guide lives at [`daytona/README.md`](daytona/README.md); the Islo guide
(including its gateway credential-injection model) lives at
[`islo/README.md`](islo/README.md).
See the [`modal`](modal/README.md), [`daytona`](daytona/README.md), [`blaxel`](blaxel/README.md), and [`islo`](islo/README.md) guides for provider setup and troubleshooting.
## Auth
@@ -516,6 +498,40 @@ to set and sanitize the identity header, and read
Run Omnigent hosts in Blaxel sandboxes from your terminal or let the Omnigent server create one for each managed session.
- **CLI-launched:**`omnigent sandbox create` ships your local checkout, then `connect` registers the sandbox as a host.
- **Server-managed:** New Chat or `POST /v1/sessions` creates a sandbox and deletes it with the session.
## Prerequisites
Install the optional Python SDK and the [Blaxel CLI](https://docs.blaxel.ai/cli-reference/introduction). Then log in to your workspace:
```bash
pip install 'omnigent[blaxel]'
brew tap blaxel-ai/blaxel
brew install blaxel
bl login your-workspace
```
The process that launches the sandbox needs Blaxel control credentials. This is your shell for a CLI launch and the server process for a managed launch. Web users log in to Omnigent, not Blaxel.
For a non-interactive server, set the credentials in its environment:
```bash
export BL_WORKSPACE=your-workspace
export BL_API_KEY=your-api-key
```
## CLI-launched sandboxes
Run `create` from an Omnigent checkout. Use a server URL that the Blaxel sandbox can reach:
```bash
omnigent sandbox create \
--provider blaxel \
--server https://your-host \
--name omnigent-dev
```
The command builds wheels from your checkout and overlays them on the standard host image. It prints the sandbox ID when the sandbox is ready.
Register that sandbox as an Omnigent host:
```bash
omnigent sandbox connect \
--provider blaxel \
--sandbox-id <id-from-create> \
--server https://your-host \
--host-name blaxel-dev
```
`connect` stays open while the host is connected. Press Ctrl-C to stop the connection. Use a unique `--host-name` when you connect more than one sandbox to the same server.
Blaxel does not expose local callback port forwarding. Omnigent therefore skips the in-sandbox browser login automatically. If the host or agent needs environment credentials, list their variable names before `create`:
```bash
export OPENAI_API_KEY=your-openai-key
export OMNIGENT_BLAXEL_SANDBOX_ENV=OPENAI_API_KEY
```
The launcher copies each named value into the sandbox. For a protected CLI target, include the credentials that `omnigent host` uses, such as `DATABRICKS_HOST` and `DATABRICKS_TOKEN`. Never include `BL_API_KEY` or `BL_CLIENT_CREDENTIALS` because Blaxel control credentials must stay outside the sandbox.
Stopping `connect` does not delete the sandbox. Delete it in the Blaxel console or with:
```bash
bl delete sandbox <sandbox-id>
```
## Server-managed sandboxes
Add the Blaxel provider to the server config. `server_url` must be a public URL that Blaxel can reach, not `localhost`:
```yaml
sandbox:
provider: blaxel
server_url: https://your-host
blaxel:
env: [OPENAI_API_KEY, GIT_TOKEN]
```
The `env` list contains names from the server environment, never secret values. See [server-config.example.yaml](server-config.example.yaml) for every provider setting.
In the Web UI, open New Chat and select **Blaxel Sandbox**. The same flow is available through the API:
The server provisions the sandbox in the background and shows launch progress in the Web UI. It gives the host a server-minted token for that launch. The user does not need Blaxel credentials. Deleting the session terminates the sandbox and removes its ephemeral file system.
All managed sandboxes use the Blaxel workspace and credentials of the server process. This integration does not map each Omnigent user to a separate Blaxel workspace.
## Image and limits
Omnigent uses `blaxel/omnigent-host:latest` by default. This public Blaxel Hub image combines the standard Omnigent host runtime with Blaxel's `sandbox-api`, which provides process, file, streaming, and lifecycle control. Use `sandbox.blaxel.image` or `OMNIGENT_BLAXEL_HOST_IMAGE` to select a fixed published tag.
| `env` | empty | Server environment variable names to copy |
| `region` | `BL_REGION` or Blaxel default | Sandbox region |
| `memory_mb` | `4096` | Sandbox memory in MiB |
| `ttl` | `24h` | Provider-side maximum age |
The host uses Blaxel keep-alive mode until the TTL or managed teardown. Process output through the provider API is limited to 4 MiB per command. Omnigent stops commands that cross this limit.
`ttl` is the single knob that sizes a managed session. It is an age from creation, not an idle timeout, so Blaxel deletes the sandbox at that age even while the session is active. The server mints each launch token for that age plus one hour, so the token never outlives the sandbox by more than the reconnect margin. A managed session that must run longer than 24 hours needs a larger `ttl`, for example `7d`. Durations accept `w`, `d`, `h`, `m`, and `s`, alone or combined as `1h30m`.
The Blaxel SDK can send SDK error events to its vendor Sentry endpoint when tracking is enabled in Blaxel configuration. Tracking is off by default. Set `DO_NOT_TRACK=1` on the Omnigent server to disable Blaxel SDK telemetry.
## Run the live smoke test
The smoke test refuses known production workspace names. It creates one unique sandbox and always attempts deletion. Set `OMNIGENT_BLAXEL_HOST_IMAGE` only to test an image override.
uv run --extra blaxel python tests/e2e/integrations/deploy/blaxel/blaxel_smoke_test.py
```
The test checks command success and failure, binary file transfer, streaming, active-process cleanup, attach and running-state lookup, and idempotent deletion with final absence.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.