`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>
2026-08-12 00:01:03 -07:00
575 changed files with 44205 additions and 7636 deletions
@@ -5,6 +5,183 @@ 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)
This Kustomize overlay turns on the **`kubernetes`** managed-sandbox provider: a
`host_type: managed` session spawns one**runner Pod** that runs `omnigent host`
as its container entrypoint and dials back to the server over the existing
launch-token tunnel. It layers the RBAC + config the provider needs onto the
base server deployment.
`host_type: managed` session spawns a**batch/v1 Job** whose child Pod runs
`omnigent host`as its container entrypoint and dials back to the server over the
existing launch-token tunnel. It layers the RBAC + config the provider needs onto
the base server deployment.
## Launch model: entrypoint-as-host
The runner Pod's container command **is** the host. An **init container**
prepares the workspace (`mkdir` + optional `git clone`); the **main container**
then runs `omnigent host` under a tiny PID-1 reaper. The host re-parents runner
processes to PID 1, which the reaper reaps; SIGTERM is forwarded for graceful
shutdown.
The runner is launched as a **batch/v1 Job** (one Pod, `backoffLimit: 6`). The
Job's child Pod runs `omnigent host` as its container command. An **init
container** prepares the workspace (`mkdir` + optional `git clone`); the **main
container** then runs `omnigent host` under a tiny PID-1 reaper. The host
re-parents runner processes to PID 1, which the reaper reaps; SIGTERM is
forwarded for graceful shutdown.
The launch token is delivered through a **per-Pod Kubernetes Secret** referenced
The launch token is delivered through a **per-Job Kubernetes Secret** referenced
by the Pod's `secretKeyRef` — it never enters the Pod spec, a command line, or
an audit log. The launcher creates that Secret at provision and deletes it
alongside the Pod at terminate.
alongside the Job at terminate.
Because the host is **never started by `exec`-ing into an already-running
container**, this provider needs **no `pods/exec` grant** — and avoids the
exec-into-running-container class of runtime issues entirely. The server SA's
rights are the minimum the launcher calls: create/get/delete Pods, get
`pods/log` (start-failure diagnostics only), create/delete Secrets (the per-Pod
token), and list events.
rights are the minimum the launcher calls: create/get/delete Jobs,
list/get Pods (to poll the Job's child), get `pods/log` (start-failure
diagnostics only), create/delete Secrets (the per-Job token), and list events.
## Two-namespace, least-blast-radius design
| Namespace | Holds |
|---|---|
| `omnigent` | the server, its DB/PVC, its Secrets, the `omnigent-server` SA |
| `omnigent-sandboxes` | runner Pods, the per-Pod token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
| `omnigent-sandboxes` | runner Jobs (and their child Pods), the per-Job token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
The server SA's Pod/Secret rights are a **namespaced Role** bound (cross-namespace)
to `omnigent-sandboxes` only — so a compromised server can manage runner Pods but
**cannot** delete the server/DB Pods, read the server's Secrets, or execute
commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
The server SA's Job/Pod/Secret rights are a **namespaced Role** bound
(cross-namespace) to `omnigent-sandboxes` only — so a compromised server can
manage runner Jobs but **cannot** delete the server/DB Pods, read the server's
Secrets, or execute commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
the generated runner Pod is already restricted-compliant (non-root uid 1000, drop
`ALL` caps, `seccompProfile: RuntimeDefault`, no privilege escalation).
@@ -83,7 +84,7 @@ runner Pod unexpectedly carries no credential:
(`omnigent/server/managed_hosts.py`), e.g. "agent … is not a genuine built-in;
omitting agent label".
- A name that is not a valid label value logs a `WARNING` from
`build_pod_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
`build_job_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
"agent … is not a valid omnigent.ai/agent value; runner Pod … stays
unclassified". Note the gate upstream will already have logged this agent as
classified, so this is the line that explains the missing label.
Omnigent release features are deployment-wide, temporary rollout switches. They
are not authorization controls or user preferences.
## Configuration
Set the comma-separated `OMNIGENT_FEATURES` environment variable and restart or
redeploy the server:
```bash
OMNIGENT_FEATURES=usage_page,harness_install
```
Unset or empty means every release feature is off. Unknown names fail startup.
The former `OMNIGENT_HARNESS_INSTALL_ENABLED` switch is rejected with a
migration hint; use `OMNIGENT_FEATURES=harness_install` instead. The server resolves the set once at startup and publishes frontend-visible
values in `GET /v1/info` under `features`. Users must reload the web app after a
flag change because server capabilities are cached at page boot.
`omnigent/server/feature_flags.py` is the source of truth for known keys and
lifecycle metadata.
## Inventory
| Key | Default | Owner | Review by | Purpose |
| --- | --- | --- | --- | --- |
| `usage_page` | Off | Web | 0.11.0 | Exposes the web Usage route, sidebar navigation, timeline, and cost breakdown details. The existing `GET /v1/usage` CLI API remains available while off. |
| `harness_install` | Off | Onboarding | 0.11.0 | Allows the web UI to install or configure supported harnesses on a connected host. |
At the review release, each flag must be removed by making the feature
unconditional, removing the feature, or moving a genuinely permanent operator
policy into normal server configuration.
## Rollout and rollback
1. Deploy an immutable image with the feature absent from `OMNIGENT_FEATURES`.
2. Enable it on one deployment, consistently across all replicas.
3. Verify `GET /v1/info`, then reload and exercise the gated UI.
4. Expand by deployment cohort.
5. Roll back by removing the key and redeploying the same image.
@@ -68,6 +68,21 @@ an empty DB they self-seed a small fallback session over HTTP (the
`external_conversation_item` event — appends items without starting a task), so
they still work with no runner or LLM.
### Hook spawn (no server)
| Journey | Operation timed |
| --- | --- |
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
lifetime is user-visible streaming latency, and the same interpreter+import
cost fronts every statusline refresh and per-tool-call policy hook. The
journey needs no server or runner; registering it here rides hook spawn cost
on the same nightly/release regression comparison as everything else
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
import-graph side of the guarantee is pinned deterministically by
@@ -50,7 +50,7 @@ Run it from anywhere inside the checkout — it walks up to the repo root
|---|---|---|
| server | `uv run omnigent --log-to-stderr server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
| host | `uv run omnigent --log-to-stderr host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
| vite | `pnpm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
| vite | `pnpm run dev --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
Before Vite starts (and on a manual Vite restart), omnidev runs `pnpm install`
in `web/` when needed — `node_modules/` is missing, or `package.json` /
- Python deps via `uv` only (never pip); JS/TS via `bun`. Latest stable deps.
- Pre-commit gate (must pass): `uv run ruff check --fix && uv run ruff format && uv run mypy --strict . && uv run pytest`. Never disable a lint/type rule — fix the root cause.
- Pre-commit gate (must pass): `uv sync --group dev && uv run --no-sync ruff check --fix && uv run --no-sync ruff format && uv run --no-sync pyrefly check && uv run --no-sync pytest`. Never disable a lint/type rule — fix the root cause.
- agy pinned: `AGY_EXPECTED_VERSION=1.0.10` (Docker build fails on mismatch). All RPC shapes are version-sensitive.
- [ ] **Step 1:** Grep for `antigravity_native_forwarder` / `forwarded_steps` / `update_forwarded_steps` references; confirm only the reader path remains.
- [ ] **Step 2:** Delete the forwarder module + its tests; remove the cursor fields/methods from the bridge; relocate the shared types.
- [ ] **Step 3:** Run the full gate: `uv run ruff check --fix && uv run ruff format && uv run mypy --strict . && uv run pytest` (targeted antigravity suites + server).
- [ ] **Step 3:** Run the full gate: `uv sync --group dev && uv run --no-sync ruff check --fix && uv run --no-sync ruff format && uv run --no-sync pyrefly check && uv run --no-sync pytest` (targeted antigravity suites + server).
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.