Compare commits

...

169 Commits

Author SHA1 Message Date
harry-yao_data d33863faeb docs(benchmarks): stop native_hook_spawn claiming to be the per-chunk path
The journey's comment, docstring, README row and CLI description all say
it spawns "the per-chunk MessageDisplay hook exactly as Claude Code
does". That stopped being true when MessageDisplay moved to a /bin/sh
appender and evaluate-policy moved to a curl against the runner's relay.
Both are pinned by tests — test_message_display_shell_command_round_trips
asserts "python" is absent from the installed command — so the number the
journey reports (~40ms here) is not on any per-chunk or per-tool-call
path.

Left as it was, the number reads as ~40ms of blocked TUI per streamed
chunk, which would make it the largest single cost in the system and the
obvious thing to go fix. It isn't, and I went and measured a replacement
for an optimization the repo already has.

Say what it measures instead: the lifetime of a hook that is Python,
which is what the per-turn hooks (SessionStart / Stop / UserPromptSubmit
/ PreCompact / Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and
the policy hook's pre-relay fallback still pay — and which is the
standing argument for keeping the hot paths off the interpreter. Naming
the tests that pin it points the next reader at the evidence rather than
at a stale comment.

No behaviour change; comments, docstring, description and README only.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: harry-yao_data <harry.yao@databricks.com>
2026-08-22 08:50:20 +00:00
Aravind Segu 5d7aa85132 fix(cli): forward non-uuid session ids on remote resume (#5218)
`omnigent resume <id>` canonicalized every id through the local sqlite store's uuid rule (uuid_to_bytes), even when --server points at a remote server that owns its own id space. A deployment that keys sessions on non-uuid ids (e.g. numeric node ids) had every id rejected client-side with "Invalid session id." before any request was sent.

Only the local path binds the id to the Uuid16 column, so keep the strict uuid guard there; on the remote path forward the id untouched and let the server resolve it, matching how the runner and SDK already pass the id straight through.

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
Signed-off-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-21 15:54:45 -07:00
Danny Sievers 243b13abb3 fix(ios): delegate OIDC login to system browser (#2556)
* fix(ios): delegate OIDC login to system browser

Signed-off-by: dannysievers <danjsievers@gmail.com>

* test(e2e_ui): cover iOS OIDC browser handoff

Signed-off-by: dannysievers <danjsievers@gmail.com>

* test(e2e_ui): avoid exfil scan false positive

Signed-off-by: dannysievers <danjsievers@gmail.com>

---------

Signed-off-by: dannysievers <danjsievers@gmail.com>
2026-08-21 15:38:03 -07:00
Tomu Hirata d20c465457 fix(codex): serialize stdin writes to prevent parallel tool-call interleaving (#5229)
When Codex emits parallel tool calls, the harness processes them
sequentially but _send_message's write+drain sequence is not atomic:
a concurrent caller can write() between another caller's write() and
drain(), interleaving bytes on the subprocess stdin pipe.  The Codex
app-server then reads a corrupted JSON-RPC line, drops the response,
and the remaining tool outputs never arrive — causing the turn to stall
for minutes before timing out.

Fix: guard _send_message with an asyncio.Lock (_stdin_lock) so that
the write/drain pair is always atomic.  The lock is initialized in
__init__ alongside the other per-session state.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-21 22:24:09 +00:00
Tomu Hirata f443086bd6 fix(cli): close daemon stdin to release terminal on shell exit (#5228)
The host daemon inherited stdin from the spawning shell, holding an open
fd to the pseudo-terminal (/dev/pts/N). Even with start_new_session=True,
the shell blocks on exit until all processes with that fd close it — so
users running 'isaac omni codex' in arca were forced to manually kill the
daemon before they could exit.

Passing stdin=subprocess.DEVNULL redirects the daemon's stdin to /dev/null
at spawn time, releasing the terminal fd immediately.

Fixes OMNI-3274.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-22 07:23:32 +09:00
Pat Sukprasert 203eb3e589 fix(e2e): de-flake test_repl_approve_always_caches_for_later_turns PTY timing race (#5224)
Wait for deterministic LLM response content instead of the racy toolbar marker.

The test was using _wait_for_turn_complete() which waits for the toolbar's
· ready marker. Under CI load, this marker can appear before Turn 2's actual
output (the "auto-approved" audit line and LLM response) renders, causing
child.before to be captured prematurely with only the input echo. This is a
classic PTY timing race.

The fix: wait for the scripted LLM response "Following up as requested." which
only appears after the full turn has rendered, including the required
"auto-approved" audit line. This synchronization pattern is already used
successfully in test_repl_two_turns_fires_one_approval_per_turn (line 602).

Verified: 10 consecutive runs all passed (was flaky ~1-3% on CI).

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-21 21:21:46 +00:00
Pat Sukprasert 981c4fff3e fix(ci): relay fork review hygiene (#5063)
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-22 04:08:25 +07:00
Pat Sukprasert 8103829ef8 fix(codex-native): honor Max/Ultra reasoning levels instead of coercing to xhigh (#5217)
* fix(codex-native): honor Max/Ultra reasoning levels instead of coercing to xhigh

Codex advertises a per-model reasoning ladder via model/list: Sol reaches
`ultra`, Luna reaches `max`, and a turn at those levels completes (Sol's
`ultra` runs subagents). Omnigent's picker surfaced them, but the codex-native
effort override validated against the xhigh-capped CODEX_EFFORTS ladder, so a
web-picked Max/Ultra was silently coerced to xhigh before the wire — and the
TUI->web effort mirror stored `ultra` as `xhigh`, so the UI showed the wrong
level.

Validate codex-native efforts against the full codex ladder
(CODEX_NATIVE_EFFORTS) and add `ultra` to the session-metadata vocabulary, so a
picked level rides through unchanged and the UI shows the real level. The
SDK/Responses codex path keeps the xhigh cap + ultra/max alias, preserving
OMNI-1694's defensive fold on that backend.

OMNI-4255

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* fix(web): follow the drafted model's effort ladder in the Configure Codex modal

The effort dropdown mapped a static effortLevels prop computed from the
committed model, so switching the model inside the modal (Sol → Luna) still
listed Sol's `ultra`, with the stale level left selected. Recompute the ladder
from the drafted model and drop a picked level the new model doesn't offer, so
the dropdown never shows a rung the model rejects and Save can't submit one.

OMNI-4255

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* chore(codex): ultra parity in REPL /effort, schema doc, and GLM effort cap

Address Polly's non-blocking notes on #5217, now that `ultra` is a
first-class effort value:
- REPL `/effort` accepts and lists `ultra` (matched EFFORT_VALUES).
- schemas.py reasoning_effort docstring enumerates `ultra`.
- GLM effort cap treats `ultra` as unsupported — every rung above `high` —
  so a pinned `ultra` clamps to medium like `xhigh`/`max`.

OMNI-4255

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-21 20:52:14 +00:00
Yuan Tang 03b6ee51f5 feat(scheduled): auto-attach cost_budget policy on fire (#4791)
* feat(scheduled): auto-attach cost_budget policy on fire

Add an optional `max_cost_usd` field to scheduled tasks. When set, the
fire path attaches a `cost_budget` policy to each spawned session,
capping cumulative LLM spend at the configured limit. This prevents
runaway token spend from unattended scheduled runs.

The attachment is non-fatal: if the policy store is unavailable or the
create fails, the session proceeds uncapped (an uncapped session is
better than a dead run).

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: chain migration after current alembic head

Point the max_cost_usd migration's down_revision to the actual current
head (d5e9f1a2b3c4) instead of z9a2b3c4d5e6, which already had another
child — creating a branch that broke alembic's single-head requirement.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: SQLite-safe downgrade and update column set test

Use batch_alter_table in migration downgrade for SQLite compatibility.
Add max_cost_usd to the expected column set in migration tests.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: chain migration after merged main head za2b3c4d5e6f

Repoint down_revision from d5e9f1a2b3c4 to za2b3c4d5e6f so the
migration chains after the task_summary migration that landed on
main, avoiding a dual-head conflict.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

---------

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-08-21 16:22:23 -04:00
Lee moon soo a8b030b786 fix(runner): keep native harness alive during terminal activity (#5212)
Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>
2026-08-21 12:35:22 -07:00
Hubert 0a4a8114ad Add icon when creating project (#5209)
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-08-21 18:09:45 +00:00
Hubert f7861ec494 Improve icon picking (#5170)
* Improve icon picking

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* Bugfix

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* Bugfix

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* Bugfix only [no add icon option]

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-08-21 17:17:28 +00:00
Harry Yao eeec1cedf0 fix(runner): dedupe repeat sub-agent inbox re-wake notices (#4803)
* fix(runner): dedupe repeat sub-agent inbox re-wake notices

A parent that idles holding undrained sub-agent results gets a recovery
wake notice ("[System: sub-agent X/Y finished (completed) — N results
waiting in inbox. Call sys_read_inbox to collect.]"). In a large
homogeneous fan-out the latest child's label and the pending count
plateau, so that nudge repeats the identical line at every turn boundary
— visible spam in the parent's stream.

Record the last re-wake notice delivered per parent and skip a follow-up
re-wake that matches it verbatim. The first recovery nudge for any state
still fires (a stranded inbox is never left silent), and distinct
new-completion notices always post (their count differs), so no results
are lost — only redundant repeats are dropped.

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

* chore(runner): drop explanatory comments on re-wake dedupe

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

* fix(runner): let a drained inbox clear the re-wake dedupe record

The re-wake dedupe records the last delivered recovery notice per parent
so a verbatim repeat is skipped. That record described outstanding work,
but was only dropped on session teardown, so it outlived the stranding
episode it belonged to. After the parent drained, a later fan-out round
that produced the same label and pending count had its recovery wake
suppressed as a duplicate — leaving the parent holding undelivered
results with the wake flag cleared. Once every child has finished
nothing re-arms that flag, so the results are never collected: the exact
strand the recovery wake exists to break, and reachable in the
homogeneous fan-out the dedupe targets.

Treat a drained inbox as ending the episode and forget the record, so a
new episode's notice is judged on its own. Suppression across an
intervening completion wake is unchanged.

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

---------

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-21 07:11:20 -07:00
Corey Zumar 3d537bba59 fix(claude-native): cold-resume a session on its persisted canonical model (#5167)
A pane's /model persists the exact id it runs (claude-opus-4-8), but the launch gate only accepted an exact catalog row, and a direct-login catalog spells that family as alias rows (opus -> claude-opus-5, the appended claude-opus-4-8[1m] default). Every live switch to a non-default model therefore armed a resume failure: "not in this host's current model list".

Accept a canonical Anthropic id when the endpoint serves canonical spellings and the catalog lists the id's family — the same fold /model already applies to an unpinned canonical id — and keep refusing gateways, Bedrock, and unlisted families so a stale pick still fails fast.

Covers the cold resume of a persisted pick for claude and codex in the live model-flows suite (red on claude before the fix, green after; codex has no alias layer and passes both ways), plus unit coverage of the fold and the launch gate.

Closes #5158
2026-08-21 00:47:49 -07:00
Harry Yao 67f9db8bc8 Implement Claude Code permission mode switching (#4018)
* feat(web): switch claude-native permission mode mid-session

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-20 23:14:53 -07:00
Zeyi (Rice) Fan 2c2afac192 feat(electron): clarify desktop update prompts (#5160)
## Related issue

N/A

## Summary

- Show the installed and available Omnigent Desktop versions in the shell-owned update prompt, with session-safety copy on its own line and unclipped card chrome.
- Carry the effective desktop version through updater status events and keep the native up-to-date dialog consistent.
- Add a development-only semantic-version override that checks the production update feed, making the real update flow reproducible without changing packaged behavior.

ELI5: development can pretend the installed desktop app is older, compare it with the production feed, and pass that same version into the update card.

```text
OMNIGENT_DESKTOP_VERSION_OVERRIDE
                |
                v
      electron-updater baseline ---> production update feed
                |
                v
      update status + current version ---> shell overlay
```

## Test Plan

- `node --test web/electron/test/desktop_updater.test.js`
- `node --test web/electron/test/update-main.test.js`
- `pnpm --filter web exec vitest run src/components/UpdateBanner.test.tsx`
- `pnpm --filter web type-check`
- Targeted `oxlint` over the changed Electron and web sources/tests.
- `pnpm --filter web build:overlay`
- Development flow: `OMNIGENT_DESKTOP_VERSION_OVERRIDE=0.9.0 pnpm start`, then **Server → Check for Updates…** against the production feed.

## Demo

N/A — the UI is rendered in an Electron-owned transparent child window; the focused component assertions and overlay production build cover the final copy and layout contract.

## Type of change

- [ ] Bug fix
- [x] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The updater unit harness covers version propagation, development override isolation, and production-feed configuration. The UpdateBanner test covers available, downloading, and downloaded copy.

## Changelog

Desktop update prompts now show the installed and available versions with clearer copy and polished overlay spacing.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 05:57:33 +00:00
Zeyi (Rice) Fan 733234c303 fix(electron): restore version lockstep (#5150)
## Related issue

N/A

## Summary

- Restore the Electron package version to the semver translation of the repository's Python package version.
- Make the Version lockstep check pass after the desktop version drifted to `0.10.0` on `main`.

## Test Plan

- `uv run --frozen --project ../.. python ../../scripts/update_versions.py check`
- `git diff --check`

## Demo

N/A

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [x] Test / CI
- [ ] Breaking change

## Test coverage

- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The existing version-update tests cover Electron version stamping and desktop-version drift detection.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 20:28:07 -07:00
Zeyi (Rice) Fan 039661185d chore(electron): set desktop version to 0.10.0 (#5145)
## Related issue

N/A

## Summary

- Stamp the Omnigent Electron desktop package as version 0.10.0 for the desktop release.

## Test Plan

- `node -e "const p=require('./web/electron/package.json'); if (p.version !== '0.10.0') throw new Error(p.version); console.log(p.name + '@' + p.version)"`
- `git diff --check`

## Demo

N/A

## 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
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [x] Not applicable

## Coverage notes

This is a package metadata-only version stamp; the package JSON was loaded directly to verify the resulting version.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 19:50:37 -07:00
Edwin He 4b6779febb feat(auth): refreshable credential for unattended host daemons (#4743)
* feat(auth): refreshable credential for unattended host daemons

Implements login-issued refresh grants so unattended hosts can renew
their session tokens instead of crashing when the initial JWT expires.

- Server: OIDC callback persists refresh material and issues a login-scoped
  renewal grant (30-day TTL, reuses device-grant store/rotation machinery)
- CLI: load_token() calls refresh_stored_token() on expiry, mints a fresh
  session JWT from the grant via POST /oauth/token
- Host: treats post-connection 401/403 as retryable-with-reauth (attempts
  token refresh before failing); improves error text for expired tokens
- Auth: login grants (no scope) bypass the delegated-token allowlist,
  keeping full authority; delegated tokens stay scope-restricted
- Tests: new coverage for refresh cycles, OIDC mode token router, env
  override of grant lifetime

Fixes OMNI-1127 / closes #1953.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(auth): address review findings for refreshable host credential

Fixes P0-P3 security and robustness issues discovered in review:

P0 (FEATURE-BREAKING): create_redeemed_grant called self._session() with
no query_name, causing TypeError and breaking the entire login-grant
feature. Now uses "insert_redeemed_device_grant" per CLAUDE.md conventions.

P1 (SECURITY): LoginRequest.issue_refresh was a client-controllable bool,
allowing XSS/form-hijack to obtain 30-day unattended credentials via
browser login. Removed the field entirely; browser /auth/login now NEVER
issues refresh material — only CLI/device flows do (server-side enforcement).

P2.1 (ROBUSTNESS): _check_cookie dropped isinstance(grant_id, str) guard,
allowing malformed grant_id claims to reach _grant_revoked(). Restored guard.

P2.2 (ROBUSTNESS): _store_entry assumed token file was dict but _load_entry
guards with isinstance(data, dict). Mirror the guard on write to prevent
TypeError on corrupt files. Treat non-dict as empty (fail-safe).

P3.1 (CLEANUP): load_token's expiry warning said "attempting automatic
refresh" but load_token never refreshes. Reworded to reflect actual behavior.

P3.2 (CLEANUP): _make_client_secret_gate was built twice (once in
create_device_auth_router, again in included create_oauth_token_router),
duplicating env reads and logs. Pass gate as parameter to avoid rebuild.

P3.3 (CLEANUP): _grant_max_lifetime_seconds() re-parsed os.environ on every
refresh/purge. Now called once at router mount, captured in closure.

Added regression tests:
- test_redeemed_grant_persistence_regression: grant row must be created
  (catches P0 TypeError)
- test_browser_login_never_issues_refresh_token: browser login must NOT
  return refresh_token (catches P1 client-controllable flow)

Follow-up (test reconciliation + lint, from running the suite):
- Re-point the login-grant round-trip and session-authority tests to mint
  via issue_login_grant (the CLI/device path) now that browser /auth/login
  no longer returns refresh material.
- Fix pre-existing runner-entry test lag: load_token mocks now accept the
  min_remaining_seconds kwarg the factory passes, and drop a stray
  OMNIGENT_RUNNER_DELEGATED_AUTH that contradicted a test's documented
  no-delegation scenario.
- Fix a latent NameError: _grant_max_lifetime was referenced in
  create_device_auth_router but only bound in create_oauth_token_router;
  resolve it once at mount in the device router too.
- Remove the now-dead device_grant_store wiring from the accounts auth
  router (its only use was the removed issue_refresh path).

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(host): escalate to a re-auth prompt on sustained post-connect auth rejection

A host rejected with 401/403 after it has already connected retries forever, so a transient VPN or proxy drop self-heals. Until now it only logged "check your VPN/network", so a permanently-rejected credential (a revoked or expired grant) looped silently and never told the operator to re-authenticate. After a sustained streak it now escalates with a louder warning plus a stderr line naming the omnigent login command, re-emitted periodically. It stays retryable and never fatal, so a recoverable daemon is not killed.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(cli): use one import style in the refresh test

Drop the mixed import omnigent.cli_auth + from-import inside test_refresh_survives_unwritable_state_dir; call store_token/refresh_stored_token via the ca alias. Resolves the code-quality bot finding on the PR.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(cli): drop dead accounts issue_refresh and cover the refresh-factory wiring

Accounts /auth/login issues no refresh material (only the OIDC CLI-ticket flow does), so the accounts-login POST no longer sends the ignored issue_refresh field, and its misleading "older servers ignore it" comment is removed. Updates the CLI accounts-login test that asserted the field.

Also adds a runner-entry test proving the load->refresh->fallback auth-token factory returns the refreshed token when the stored OIDC token has lapsed but a refresh grant is present — the integration that actually keeps an unattended host alive, previously only unit-tested at the refresh function itself.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(server): mount login-grant token router only for UnifiedAuthProvider

The elif that mounts /oauth/token for login grants when the device flow is off only checked for a grant store, leaving auth_provider typed as the base AuthProvider (pyrefly bad-argument-type at the create_oauth_token_router call) and — for a non-Unified custom provider with a store — a latent runtime failure in _resolve_signing_config. Guard the branch on isinstance(auth_provider, UnifiedAuthProvider), matching the sibling device-flow branch.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(e2e): accept min_remaining_seconds in managed-runner load_token mocks

The auth-token factory now calls load_token(url, min_remaining_seconds=...); two managed-runner e2e tests monkeypatched load_token with a lambda that rejected the kwarg, raising TypeError. Accept **_kw, matching the runner-entry unit mocks.

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>
2026-08-20 19:14:41 -07:00
Zeyi (Rice) Fan f914095c1d fix(cli): preserve Databricks profile shorthand (#5143)
## Related issue

[OMNI-4272](https://linear.app/omnigent/issue/OMNI-4272/add-a-global-profile-option-to-omni-cli-for-profiling)

## Summary

- Rename the global CPU profiler flag from `--profile` to `--profiling` so it no longer collides with the existing Databricks `--profile NAME` option.
- Preserve the historical bare-run shorthand `omni --profile my-sp ...` while keeping CPU profiling available for every explicit command.
- Add regression coverage for both options used independently and together.

**ELI5:** `--profiling` measures performance; `--profile NAME` continues to choose Databricks credentials.

```text
omni --profiling COMMAND       -> CPU profile
omni --profile my-sp -p "..."  -> run with Databricks profile my-sp
```

## Test Plan

- `uv run ruff format omnigent/cli.py tests/cli/test_cli.py`
- `uv run ruff check omnigent/cli.py tests/cli/test_cli.py`
- `uv run pytest -q tests/cli/test_cli.py::test_global_profiling_writes_summary_and_timestamped_stats tests/cli/test_cli.py::test_removed_ad_hoc_detection tests/cli/test_cli.py::test_help_groups_harnesses_and_other_commands tests/cli/test_cli.py::test_run_profile_sets_databricks_config_profile_env tests/cli/test_cli.py::test_bare_run_profile_shorthand_still_selects_databricks_profile tests/cli/test_cli.py::test_global_profiling_coexists_with_run_databricks_profile`

## Demo

N/A

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The tests exercise the real module entry point for CPU profiling and `main()` argv rewriting for the historical Databricks profile shorthand.

## Changelog

Use `omni --profiling COMMAND` for CPU profiling without conflicting with Databricks `--profile NAME` selection.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 18:57:13 -07:00
Zeyi (Rice) Fan 269dffacb6 feat(cli): add global CPU profiling (#5141)
## Related issue

[OMNI-4272](https://linear.app/omnigent/issue/OMNI-4272/add-a-global-profile-option-to-omni-cli-for-profiling)

## Summary

- Add a global `omni --profile COMMAND` flag so developers can profile any CLI command with Python's built-in `cProfile`.
- Print actionable Omnigent-only cumulative and self-time summaries while preserving the complete timestamped `.prof` data under `~/.omnigent/profiles/`.
- Keep the existing `omni run --profile NAME` Databricks credential option compatible, including when both profile options are used together.

**ELI5:** Put `--profile` before a command to see which Omnigent functions made it slow; open the saved `.prof` file when deeper analysis is needed.

```text
omni --profile COMMAND
        |
        v
     cProfile
      /    \
focused stderr  full timestamped .prof
```

## Test Plan

- `uv run ruff format --check omnigent/cli.py tests/cli/test_cli.py`
- `uv run ruff check omnigent/cli.py tests/cli/test_cli.py`
- `uv run --group test pytest -q tests/cli/test_cli.py::test_global_profile_writes_summary_and_timestamped_stats tests/cli/test_cli.py::test_removed_ad_hoc_detection tests/cli/test_cli.py::test_help_groups_harnesses_and_other_commands tests/cli/test_cli.py::test_run_profile_sets_databricks_config_profile_env tests/cli/test_cli.py::test_global_cpu_profile_coexists_with_run_databricks_profile tests/cli/test_cli.py::test_run_profile_wins_over_preset_env tests/cli/test_cli.py::test_run_without_profile_leaves_preset_env_untouched`
- Manually ran `OMNIGENT_DATA_DIR=<temp-dir> uv run python -m omnigent --profile version` and inspected the focused summary and generated `.prof` file.

## Demo

N/A

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Verified the real `python -m omnigent` entry point creates a loadable timestamped profile, renders only actionable Omnigent functions in the summary, and preserves the separate Databricks `run --profile NAME` behavior.

## Changelog

Use `omni --profile COMMAND` to print focused CPU profiling results and save the full profile for deeper analysis.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 01:32:32 +00:00
Zeyi (Rice) Fan 55459d5df9 chore(packaging): remove deprecated memory extra (#5139)
## Related issue

https://linear.app/omnigent/issue/OMNI-4270/remove-memory-extra

## Summary

- Remove the deprecated `memory` compatibility alias now that `hindsight` is the canonical extra.
- Regenerate the normalized lockfile so published package metadata no longer advertises `memory`.

## Test Plan

- `uv run --no-sync pytest -q tests/onboarding/test_extra_install.py`
- Build the wheel and inspect its `METADATA` to confirm `hindsight` remains in `Provides-Extra` and `memory` does not.
- `git diff --check`

## Demo

N/A — packaging-only change.

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [x] 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

Verified the built wheel's optional dependency metadata directly; the existing onboarding extra tests continue to pass.

## Changelog

The deprecated `omnigent[memory]` extra has been removed; use `omnigent[hindsight]` instead.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 01:19:28 +00:00
Zeyi (Rice) Fan 2fbd660f55 fix(web): copy tmux selections to the system clipboard (#5136)
## Related issue

[OMNI-4259](https://linear.app/omnigent/issue/OMNI-4259/cannot-copy-out-of-tui-in-web-desktop-app)

## Summary

- Forward tmux paste-buffer changes from control-mode terminals to the active owner’s browser clipboard, with payload limits, recent-input gating, and read-only isolation.
- Support safe tmux-generated OSC 52 writes for PTY terminals while disabling them when passthrough could let pane output bypass tmux’s clipboard policy.
- Add browser permission fallback UI, preserve terminal focus, and cover the bridge, routing, parser, and clipboard behavior with focused tests.

**ELI5:** When a terminal app says “copy this,” the runner now carries that text back to the browser, which places it on the computer’s real clipboard instead of leaving it only inside tmux.

```text
tmux copy buffer ── control notification ──> WebSocket JSON ──┐
tmux PTY copy   ── safe OSC 52 ───────────────────────────────┤
                                                              v
                                                     browser clipboard
```

## Test Plan

- `uv run pytest tests/terminals/test_control_bridge.py -q`
- `uv run pytest tests/terminals/test_ws_bridge.py -k 'osc52_capability or forward_pty_to_ws' -q`
- `TMPDIR=/tmp uv run pytest tests/inner/test_terminal.py::test_server_survives_inner_process_exit_real_tmux tests/inner/test_terminal.py::test_launch_enables_csi_u_extended_keys_quietly -q`
- `uv run pytest tests/runner/test_terminal_resource_attach_ws.py -k 'selects_control_bridge_on_transport_query' -q`
- `cd web && npm test -- --run src/components/blocks/TerminalSession.test.ts src/components/blocks/TerminalView.test.tsx`
- `cd web && npx tsc -p tsconfig.app.json --noEmit`
- Targeted Ruff, Oxlint, Prettier, and `git diff --check`.

## Demo

N/A — no screenshot or recording was captured for the terminal protocol integration.

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] 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
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Focused tests cover tmux buffer notifications, bounded reads, cancellation cleanup, read-only and recent-input behavior, PTY OSC 52 capability negotiation, passthrough hardening, browser parsing, permission fallback, focus restoration, and transport routing.

## Changelog

Text copied by terminal apps now reaches your system clipboard in the web terminal.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 17:30:55 -07:00
Zeyi (Rice) Fan 275465d862 feat(electron): notarize macOS DMG releases (#5132)
## Related issue

N/A

## Summary

- Sign, notarize, staple, and validate each final macOS DMG from the existing one-command release build.
- Reuse the app notarization credentials and fail the release if Apple rejects a DMG or stapling fails.
- ELI5: the app was already sealed and checked by Apple; this also seals and checks the downloadable disk image around it.

```mermaid
flowchart LR
  A[Sign and notarize app] --> B[Build and sign DMG]
  B --> C[Notarize DMG]
  C --> D[Staple and validate]
```

## Test Plan

- `cd web/electron && node --test test/notarizeDmg.test.js`
- `uv run pre-commit run --files web/electron/package.json web/electron/build/afterAllArtifactBuild.js web/electron/test/notarizeDmg.test.js web/electron/README.md`
- Live Apple submission was not run locally because it requires release credentials.

## Demo

N/A — non-visual release packaging change.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Unit tests cover credential argument construction, release command sequencing, successful notarization, and rejection handling. The release hook also validates the real codesign and stapling commands when run with Apple credentials.

## Changelog

macOS DMG releases are now signed, notarized, and stapled for stronger download verification.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 16:52:00 -07:00
Zeyi (Rice) Fan 93ce9a957e feat(electron): allow opt-in production DevTools (#5130)
N/A — maintainer-requested Electron debugging support.

- Keep the production Electron shell locked down by default while allowing packaged macOS builds to opt into DevTools through `defaults write ai.omnigent.desktop DeveloperMode -bool true`.
- Use the same developer-mode decision for the Debug menu and the main window's DevTools capability, without relaxing update security gates.
- Document the opt-in and cover the preference decision and main-process wiring.

ELI5: development builds keep their debugging tools; a production build only unlocks them when the local macOS user explicitly sets the app preference.

```text
app launch
  ├─ unpackaged build ───────────────────────> DevTools enabled
  └─ packaged build + macOS DeveloperMode ──> DevTools enabled
                   otherwise ────────────────> DevTools disabled
```

- `node --test web/electron/test/developer_mode.test.js web/electron/test/main.test.js web/electron/test/update-main.test.js`
- `web/node_modules/.bin/oxlint --deny-warnings --report-unused-disable-directives web/electron/src/developer_mode.js web/electron/src/main.js web/electron/test/developer_mode.test.js web/electron/test/main.test.js web/electron/test/update-main.test.js`
- `(cd web && node_modules/.bin/tsc -b)`
- `git diff --check`

N/A — this is an opt-in main-process configuration change with no default UI change.

- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

- [x] Unit tests added / updated
- [x] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

The unit matrix covers development, packaged macOS opt-in/out, unsupported platforms, and read failures. The main-process harness verifies Debug-menu gating, and a wiring guard verifies the shell window uses the same decision.

Packaged macOS builds can opt into Electron Developer Tools with a local `defaults` setting.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 23:43:14 +00:00
Harry Yao a101840bbc perf(native): overlap session create with the host-online poll (#5091)
On a fresh launch the CLI created the session (`POST /v1/sessions`,
~2.1s) and then waited for the host daemon to register its tunnel
(`GET /v1/hosts/{id}`, ~0.6s), one after the other. Neither depends on
the other: the create needs no host, and the host poll needs no session.
`claude_native` already ran the two concurrently; every other daemon
launch path paid the sum.

Run them under one `asyncio.gather` in `omnigent run` and in the ten
remaining native harnesses (codex, pi, cursor, goose, kimi, qwen,
opencode, hermes, kiro, antigravity), so startup costs the longer of the
two rather than both. The post-create host wait is now guarded by
`fresh_session`, since the fresh path has already done it; a resume still
waits for the host on its own.

For `omnigent run` this also meant lifting the session create/fork into a
`resolve_session()` coroutine so it can run underneath the daemon
client's host poll, instead of opening and closing the SDK client first.

Co-authored-by: Isaac

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-08-20 16:40:52 -07:00
Lee moon soo 5689ef33ee perf(host): shorten tunnel recovery (#4981)
* perf(host): shorten tunnel recovery

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

* Preserve loopback reconnect tolerance

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

* Trigger CI retry

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

---------

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>
2026-08-20 23:31:17 +00:00
Zeyi (Rice) Fan 9a0e24213b fix(electron): preserve Databricks organization in server URLs (#5133)
## Related issue

N/A

## Summary

- Preserve the Databricks `o` organization selector while normalizing workspace URLs, but strip query parameters from non-workspace and Databricks Apps URLs.
- Keep `?o` through workspace expansion and normalize persisted recent targets before returning them to the setup page.
- Render recent servers as `host` or `host/?o=…`, hiding schemes and the internal `/omnigent` mount without changing the reconnect target.

ELI5: keep the workspace identifier users need, while hiding internal URL details and unrelated query parameters.

```text
stored target -> normalized reconnect URL -> compact recent-server label
```

## Test Plan

- `node --test web/electron/test/url.test.js web/electron/test/main.test.js`
- `uv run --no-sync pytest tests/e2e_ui/desktop/test_setup_connect.py::test_recent_server_connect_and_copy_actions_are_independent tests/e2e_ui/desktop/test_setup_connect.py::test_shared_url_module_defaults_scheme_in_browser`
- `pre-commit run --files tests/e2e_ui/desktop/test_setup_connect.py web/electron/setup/index.html web/electron/src/main.js web/electron/src/url.js web/electron/test/main.test.js web/electron/test/url.test.js`

## Demo

N/A — this changes recent-server text and URL normalization without changing layout.

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [x] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Unit and browser E2E coverage verify Databricks-only `o` preservation, workspace expansion, recent-target normalization, and compact labels.

## Changelog

Electron recent servers now retain Databricks organization links while showing compact server names.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 23:26:15 +00:00
Corey Zumar 1732faf3f3 feat: one harness-truth source for every model surface (listings, defaults, reports, confirmed switching) (#5022)
* feat(host): answer pre-launch model listings by probing the real harnesses

The pre-launch pickers were fed by catalog reconstruction — for a
Databricks-gateway codex host, serving-endpoint name enumeration: id
spellings the gateway's codex surface does not route, chat-only traps
(gpt-oss), no display names or effort ladders. The harness itself is
the only authority on what its /model picker would offer, so the host
now asks the harnesses:

- codex-native: probe_codex_model_options boots codex app-server with
  the SAME Databricks materialization a session launch gets (shared
  _databricks_launch_materialization, extracted from
  build_codex_native_server so the two cannot drift), a persistent
  probe CODEX_HOME (codex's own models_cache ETag makes refreshes
  cheap), and passes model/list rows through verbatim with a single
  default marker (launch pin first, else codex's own). Scoped to
  Databricks-profile launches; everything else — and every probe
  failure — falls open to the existing catalog path unchanged.
- claude-native: session launches (and the probe) now opt in to Claude
  Code's gateway model discovery
  (CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 in the ucode env; the
  fetch 404s harmlessly until the gateway serves /v1/models).
  probe_claude_gateway_models runs claude -p "/model" with the launch
  env so the harness executes its own discovery, then reads the
  harness-written gateway-models.json artifact — no discovery
  semantics replicated. Rows union with the configured tier rows,
  exact-id deduped. The nonessential-traffic kill-switch is stripped
  from the probe env (Claude treats it as covering discovery).
- claude-sdk: SDK-mode claude is a pass-through client with no catalog
  of its own, so the endpoint listing is the harness truth — served
  via the existing list_models_for_worker in the exact wire spelling
  the SDK sends.

Serving stays off the probe path: a new host-side cache
(omnigent/host/model_options_cache.py) keys results by a resolved-
config fingerprint, serves stale-while-revalidating with single-flight
probes, and is prewarmed per tunnel connection — measured 65ms at the
REST route warm, ~1.3s joining the prewarm probe cold. The
model-options frame is now answered from a tracked task instead of
inline on the tunnel receive loop (a cold probe there stalled every
frame — same class as a83cc707); a filesystem frame answered in 22ms
mid-probe. The REST route also stops dropping the routable_models the
frame already carries (openapi regenerated).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(host): always probe Claude Code itself for the model list

claude -p "/model" makes the harness print its own alias enumeration
headlessly, so the curated static subscription list demotes from
first resort to failure fallback. probe_claude_gateway_models
generalizes to probe_claude_model_options: it runs for every config
shape (bare subscription launches included), parses the printed
"Available:" aliases verbatim (no alias names known to the parser, so
new Claude releases flow through), and still reads the discovery
artifact when the env opts in. The host lane serves configured tier
rows (the rich spelling for pinned aliases) unioned with the
harness's printed aliases and discovered gateway rows, exact-id
deduped; the configured/static rows stand alone only when the probe
itself fails.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(host): probe Codex for every launch shape, not just Databricks routing

Same mandate as the Claude lane: the harness always answers. The probe
drops its Databricks-profile gate — a non-profile launch boots codex
app-server with whatever -c overrides the launch resolved (provider
routing, the dismissal pin, or nothing) and reads model/list verbatim,
so subscription/CLI-login and custom-provider shapes get Codex's real
visible catalog instead of the static curated list (the stale
hyphenated-id class of bug) or the raw enumeration. With no
launch-pinned model, Codex's own default marker stands. The legacy
catalog paths remain solely as the probe-failure fallback, pinned by
the existing handler tests now running with a failing probe stub.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(host): resolve Claude model aliases to concrete versions in the picker

The probed alias list answered WHICH aliases exist but not what they land
on — 'opus' could be Opus 5.0 or 4.8 and the picker couldn't say. Ask the
harness that too: each printed alias gets its own headless
'--model <alias> -p /model' run in stream-json mode, whose init event
carries the exact resolved id and whose printed 'Current model:' line
carries the human label (only the effort suffix stripped). Rows become
{id: alias, model: exact id, displayName: 'alias — label'}; the web
picker already renders displayName, so no frontend change.

Resolution runs share the enumeration run's invocation assembly so the
two cannot drift, fan out under one bounded budget (startup dominates
and stretches with box load — measured 0.7s-17s for the same command —
so one wave covers a whole alias set), and fail per-alias back to the
bare row, never the probe. Live run resolves all 10 aliases in ~6s and
surfaces facts worth not guessing: fable[1m] resolves to plain
claude-fable-5, and 'best' pins to Fable rather than Opus.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(host): show Claude picker rows as resolved versions only

Presentation pass on the resolved alias rows: the display is the
harness's resolved label alone (the alias prefix was noise), 1M-context
resolutions always say '(1M context)' even where the harness's label
omits it (sonnet[1m] prints just 'Sonnet 5'), the 'default' alias never
becomes a row (the picker renders its own Default choice, so it was a
duplicate), and aliases resolving to an earlier row's exact (model,
label) are dropped — which removes 'best' and 'fable[1m]' as the
duplicates of fable's row they currently are, without hardcoding any
alias name. Launch ids are untouched; only displayName and row
membership change.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(host): dedupe Claude picker rows by resolved model alone

opusplan resolves to claude-sonnet-5 — a model the sonnet row already
lists — so the same duplicate-model rule that removes best and
fable[1m] now covers it: one picker row per resolved model, no alias
names hardcoded. A composite-mode alias would reappear only if it ever
resolved to a model no other alias offers.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(models): drop curated picker fallbacks; sessions ride the probe

The release-curated picker stand-ins in model_fallbacks are gone — the
live harness probes are the source of truth everywhere, and a path that
cannot probe now reports nothing rather than a plausible-but-stale list
(the codex entries even carried hyphenated spellings codex itself does
not use). Smart Routing's tables stay: rankings, arm menus, and probed
exclusions are router contract data no discovery API can provide, and
the ownership test now guards those records.

Companions so nothing regresses to empty:
- The subscription sonnet_5 pick degrades to Claude's own 'sonnet'
  alias instead of hunting a static list — the harness resolves it.
- The claude-sdk pre-launch lane rides the claude probe whenever the
  endpoint listing is empty (the SDK drives the claude CLI, so the
  CLI's aliases are its truth on subscription boxes).
- Existing sessions now match the new-session picker: the runner's
  claude-model-options endpoint resolves configured rows ∪ probe once
  per session via the new shared claude_model_options_with_probe (the
  host lane uses the same composition, so the two cannot drift),
  answering 503-pending while the probe is in flight (the server fetch
  already retries those) and falling back to configured rows past a
  grace so the catalog is never empty.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): friendly composer label for Claude [1m] aliases off-catalog

The composer chip prefers the session catalog's display name, but a
Claude bracket alias the catalog doesn't list (a pick made before the
catalog carried the row, e.g. on a session launched by an older runner)
fell through to the raw id — 'sonnet[1m] High'. Render that case as
'Sonnet (1M context)': title-cased family plus the context marker, no
version claimed, since only the harness knows which Sonnet the alias
lands on. Catalog hits keep the probed display name verbatim.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): version-agnostic 'Sonnet' fallback label kills the 4.6 flash

Cold-loading a claude session painted the composer chip 'Sonnet 4.6'
for the window before the session catalog arrived, then corrected to
'Sonnet 5' — the fallback label list pinned a version that only the
harness can know (reproduced via Playwright: 'Sonnet 4.6 High' at
3.96s → 'Sonnet 5 High' at 4.70s). The fallback now says just
'Sonnet'; the catalog's display name supersedes it wherever one has
arrived, so the pre-catalog window shows a coarser label, never a
wrong one. Same honesty for the sandbox new-chat picker and the
scheduled-task model dropdown, which render the same list.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(web): retire two redundant uses of the local Claude alias list

The composer chip's pre-catalog fallback now formats alias-shaped ids
mechanically (title-case family, '_N' → ' N', '[1m]' → ' (1M context)')
instead of looking them up in CLAUDE_NATIVE_MODELS — same rendering,
zero model knowledge. The sticky-model compatibility check collapses to
session-catalog membership alone: its isClaudeNativeModel conjunct was
subsumed by the catalog check it was AND-ed with, and would have
rejected catalog rows whose ids don't look Claude-ish even though the
session's own catalog offered them. The now-orphaned guard is deleted;
the list itself stays for the genuinely hostless surfaces (sandbox
picker, unpinned scheduled tasks, schema enums).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(e2e_ui): composer label stays version-free until the catalog speaks

Covers the label behaviors the model-listing work changed: with the
session catalog held back, the composer chip renders the alias
mechanically ('sonnet[1m]' → 'Sonnet (1M context)'), and only the
arriving catalog upgrades it to its display name ('Sonnet 5 (1M
context)'). Every painted label is recorded via a MutationObserver so a
transient raw id or invented version ('Sonnet 4.6') cannot hide from a
retrying expect().

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(web): offer Smart Routing in the in-session gear for native panes

The in-session composer gear withheld the Smart Routing model option
from native Claude Code / Codex sessions under a stale premise ('their
CLI bakes the model at launch') — the server has routed native panes
per turn via /model injection since the create-time gear gained the
option, and validates routing-on creates with a per-family rule. The
in-session gate now mirrors that exact rule: a router must answer for
the session's family — the external AI-Gateway router only when the
host runs the family through the gateway (read off the session's host
row; absent rows fail open like the landing), the built-in judge
anywhere. SDK/bundle sessions keep their existing flag-only gate.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* style(web): prettier over the routing-gate and label changes

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(runner): sys_list_models subscription row is an honest empty listing

The curated claude stand-ins are gone from the static subscription
path; the dispatch test now pins the empty-models shape with the
probing note, matching the model-catalog contract.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): composer gear rides the host probe until the session catalog lands

A fresh codex session's gear showed a sparse Model row and no Effort row
for ~15s: effort levels come from the session catalog's
supportedReasoningEfforts, and that catalog only resolves once codex
app-server answers model/list. The session's host already probed the
same harness for the new-chat picker, so the gear (and the composer
chip) now falls back to those cached rows — same ids the launch accepts,
~90ms warm — whenever the session's own catalog is empty; the runner's
per-session catalog supersedes them the moment it arrives. Claude
sessions get the same pre-catalog Model list for free (their effort
levels were already static).

Verified live on a fresh codex session: Effort visible 0.5s after
create+load with the session catalog still empty, offering the host
row's levels.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): keep model-options identity stable when the host fallback is idle

The pre-catalog host fallback returned a fresh empty array whenever the
session catalog was empty and no host rows existed — for EVERY session
shape, native or not. That new identity per render re-rendered each
options consumer (composer, gear, agent-info popover) on every
streaming/liveness tick, which under CI load tipped the agent-info
hover-open grace race (shard 1 failed the same popover test twice).
Substitute only when host rows actually exist; otherwise the store's own
stable array reference flows through untouched, restoring the exact
pre-fallback behavior for every session the feature doesn't apply to.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(claude-native): apply picked model aliases verbatim, never the default

Picking Fable in a gateway session's composer switched the pane to
Opus: resolve_claude_native_model_selection swapped an unpinned family
alias for the provider's default model (a degrade from the era when
the picker always showed every family alias), the vocabulary re-spelled
that default as its pinned alias, so the runner injected '/model opus'
for a 'fable' pick — and the statusLine mirror then recorded the wrong
model as the session override. Bracket aliases had sibling failures:
'/model sonnet[1m]' 503'd on a pinned env (no spelling for it) and
silently dropped the [1m] marker on a bare login (family-segment
step-down).

Picker rows are pin-backed or probe-vouched now, so a pick passes
through verbatim and Claude owns resolution:
- the resolver's no-pin gateway degrade is gone (an out-of-band
  unpinned pick now fails visibly at inference instead of silently
  running the default);
- bracket variants of the family aliases are their own /model
  arguments in the vocabulary — the harness enumerates them itself;
- the configured∪probe union drops probe rows whose resolved model is
  a bare canonical Anthropic id on an endpoint that routes its own ids
  only: the pick could never work there, so the row is not offered
  (a pinned family resolves to the endpoint's spelling and stays).

Reproduced at the runner layer (events → resolver → injected command):
picking 'fable' asserted '/model fable' and got '/model opus' before
the fix. An e2e_ui guard pins that the web PATCHes the picked row id
verbatim — the client layer was innocent.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(claude-native): mirror pane model switches in the catalog's vocabulary

Live verification of the verbatim-alias fix exposed the last surface in
the same family: after a web pick of 'sonnet[1m]' correctly switched the
pane, the statusLine mirror collapsed the observed model back to the
LEGACY picker vocabulary — 'databricks-claude-sonnet-5[1m]' became
'sonnet_5' — stomping the just-saved override with an id the session's
catalog doesn't list (and which a relaunch would resolve through the
custom-tier branch, silently dropping the 1M context).

_model_alias_for now speaks the catalog's row ids: 1M resolutions keep
their bracket marker ('sonnet[1m]'), and the legacy 'sonnet_5' opt-in
row is mirrored only on a config whose custom slot actually pins it —
read off the session's launch pins — since everywhere else the generic
sonnet row IS that model. This also restores the designed web→TUI
round-trip no-op: the mirrored alias now equals the persisted override,
so the server-side dedupe skips the write.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(codex-native): report the model a Default launch actually runs

A Default codex launch names no model, so nothing pinned the session's
config.toml — yet the profile still resolved a concrete model and passed it
as `-c model=`, which outranks the config copied from the user's shared
~/.codex home. The pane ran the resolved model while the session reported
the shared file's leftover one: the create dialog promised
"Default (GPT-5.6-Luna)" and the session then said GPT-5.4.

Pin the profile-resolved model in codex's own spelling, so the forwarder
mirror and the cost gate read the model this session runs. Mark that model
as the catalog default too — codex's own isDefault is its built-in
preference and named GPT-5.6-Sol on a session running Luna, which also fed
the composer gear an effort ladder the running model rejects.

Web side: fold catalog and codex spellings when resolving a session's model
onto a picker row, and stop borrowing the default row's effort levels for an
unresolved model.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(runner): keep the codex model-options test off the real launch

Session create launches Codex for real, and that launch owns the bridge
dir: it clears the state and its forwarder task rewrites both the state
and CODEX_HOME/config.toml after the response returns. On a machine
where Codex and a Databricks profile resolve, that wiped the seeded
state no matter which side of create seeded it, so the endpoint answered
503. Stub the launch; the endpoint, the bridge-state read, the
CODEX_HOME read, and the fake app-server client all stay real.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): name Codex's Default the same in both model gears

The composer gear and the new-session gear each built their own copy for
the Model row, so one session read a bare "Default" in the composer and
"Default (gpt-5.6-luna)" on the landing page, and the landing page
listed raw catalog ids where the composer listed display names. Neither
gear told the user which model Codex would actually run.

Move both labels into HarnessConfigControls next to the sentinels they
belong to and read them from there in both callers. Row ids are
untouched, so picks still submit the harness's own spelling.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(claude-native): read the custom slot instead of guessing the model row

The terminal->web mirror mapped a concrete model id onto a picker row by
looking for a family name inside the id, so a routed Opus 4.9 landed on
the `opus` row that holds 4.8: the web showed the wrong model, and posting
that row back stepped the session off its launch pin. Resolve rows by
exact comparison against the launch pins instead, and read Claude Code's
one custom model slot to name its row rather than inferring it from the
model's spelling. A `[1m]` resolution stays a distinct row from its
non-bracket sibling.

The legacy `sonnet_5` row id and the substring spellings it used to be
matched by move into claude_model_vocabulary with a 0.10.0 removal note;
the substring leg now runs only when the exact comparison misses.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(web): drop the substring model-row match the picker never calls

`isModelImplicitlySelected` guessed which picker row a bound model belonged
to by searching for the row id inside the model name, which is why `sonnet`
matched `sonnet-5` and needed a special case per generation. Its only
caller sat in the branch taken when a session has no server-supplied model
list, and every native picker kind is on that list, so the branch ran with
an empty list and the call could not select anything.

Delete the function and collapse the caller to the server-list path. The
two suites that covered it go with it; nothing else exercised it.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* docs(claude-native): say plainly that gateway model discovery never fires

The launch env asks Claude Code to discover the gateway's model inventory,
and the comment claimed the only thing holding it back was a gateway that
did not serve `/v1/models` yet. The gateway serves it now, but the same env
sets CLAUDE_CODE_USE_GATEWAY, and the CLI fires that fetch only on its
first-party provider path — so the artifact is never written and the rows
read from it are always empty.

Name that in all three places a reader lands: the flag, the probe's
env-unset list (popping the nonessential-traffic switch is not enough), and
the artifact read itself.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(model-flows): add the harness-truth e2e suite, red-first

The model-flows design lands test-first: this suite encodes the target
behavior for every flow (pre-launch picker, default labels, create→launch
pane truth, gear parity, confirmed switching, terminal-side mirroring) and
is deliberately red today in the ways the analysis measured.

Two tiers. The hermetic tier drives the real SPA over the spawned server
with the session snapshot shaped at the browser edge and SSE frames pushed
through a captured stream controller; it runs in the normal e2e_ui lane.
The live tier (`live_model_flows` marker, opt-in via
OMNIGENT_E2E_MODEL_FLOWS=1) boots a real server + host from any checkout —
OMNIGENT_E2E_MODEL_FLOWS_REPO selects which, so the identical tests
produce the red-on-main matrix — flips provider shapes the way setup
writes default claims, launches real claude/codex TUIs, and asserts pane
truth over tmux.

Recorded pre-implementation: hermetic 4 red / 2 guard-green (the design's
predicted set exactly); live rows 1 and 5 red against unmodified main
(the frozen "Sonnet 4.6" static list; the empty/erroring codex pre-launch
answer).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(models): verbatim reported_model as the single display authority

Landing-order step 3 of the model-flows design. Sessions gain a
reported_model — the model the harness last said it is actually on, in
the harness's own spelling — stored as a new key in the existing
session_overrides blob (no DDL) and served on the snapshot's llm_model
field with precedence reported ?? spec model. external_model_change
writes and dedupes against it verbatim; the user's request
(model_override) is untouched, because requests and reports are separate
roles and only reports are ever displayed.

The claude forwarder now posts the status file's model byte-for-byte:
the alias-collapse mapper (_model_alias_for / _custom_slot_row_id) is
deleted — collapsing a routed Opus 4.9 onto the opus row holding 4.8 is
the bug class this kills — and the first-observation-silent-seed rule is
gone, so the launch's own model reports within seconds of spawn and the
composer is never blank-forever. The codex forwarder already posted raw
ids and needed no change.

The web renders and highlights models from the reported value alone:
exact id/model match against the catalog, with an off-catalog report
appended as its own raw row rather than relabeled onto a same-family
row. The sticky model becomes a pure preference — the silent bind-time
and delayed-catalog model_override PATCHes are removed (they wrote
requests the pane was never asked to honor), and session.model events
land on llmModel instead of the picker selection. Cost attribution
prefers the reported model too.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(models): explicit launches from the shared catalog store

Landing-order step 4. Every native launch now pins its model explicitly,
resolved through the new on-disk catalog store
(omnigent/model_catalog_store.py — one probe result under a
launch-config fingerprint, read by every consumer), so nothing is left
to invisible CLI-private state and a stale config line can never govern
a session.

Claude: the enumeration probe runs in stream-json and captures its own
init-event model — the truthful Default — so claude_model_catalog marks
exactly one isDefault row (appending an off-list default, e.g. a
settings.json pin, as its own launchable row; never appending a bare
Anthropic spelling on an endpoint that rejects it). A Default
subscription launch passes --model with that default; an explicit
request is validated against the catalog and fails the launch loudly
when the list no longer carries it. The runner also records the launch
vocabulary onto the bridge after config resolution
(record_model_vocabulary), closing the model_env gap that made
mid-session /model conversion read the runner's ambient env.

Codex: the session-shaped probe home now links the account's real
auth.json (the catalog must answer for the account that will run — the
Sol-promised/Terra-offered mismatch dies here), a Default launch on
codex's own login resolves the account's real default instead of
inheriting the copied config line (the stale-gpt-5.4 400 class), and
build_codex_native_server emits -c model= alongside the config-copy pin
from one resolved value on every shape. A guard test pins the
argv/config-pin agreement across all provider shapes; on the profile
shape the file deliberately keeps codex's own spelling and the guard
asserts same-model rather than same-bytes (addendum).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Serve every model surface from the shared harness catalog

One catalog file per (harness, launch-config fingerprint) now backs the
pre-launch picker, launch resolution, and the in-session gear:

- Host: catalog-backed model-options handlers with a concurrent, detached
  boot prewarm; probe failures answer ok+[] plus an error string the web
  new-session dialog displays. The in-memory ModelOptionsCache module is
  removed.
- Runner: unified GET /v1/sessions/{id}/model-options (harness-named
  routes stay as deprecated aliases until 0.11.0); the claude route waits
  briefly on the store's single-flight probe (503-pending past that) and
  the codex route writes live listings back to the store.
- Server: model-options loads go unified-first and fall back to the
  legacy route on 404; the hosts API forwards the host's error string.
- Deletions: static claude alias table, gateway-discovery artifact
  machinery, the configured-union composition, and the host's codex
  catalog reconstruction lanes.
- Tests isolate the catalog store per test so suites cannot touch the
  developer's real ~/.omnigent cache or boot real harness CLIs.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Confirm model switches through the harness before claiming them

Switching is now ask -> pending -> harness-confirmed on every lane:

- Runner (claude): after typing /model, verify against the statusLine
  snapshot the forwarder already polls (10s budget) — expected spellings
  come from the session's own catalog rows; a pane that never switches
  answers 503 so the server surfaces the swallowed-dialog case instead
  of the row silently claiming the pick. A shape with no snapshot stays
  unverifiable-but-successful.
- Runner (codex): the awaited thread/settings/update RPC is the
  confirmation; a missing Codex bridge now answers 503 instead of a
  silent 204, and plan-mode updates re-assert the reported model rather
  than a stale override.
- Server: the visible model_change_not_applied notice now carries the
  runner's own detail string.
- Web: a transient pendingModelChange marks the ask (spinner beside the
  composer chip); the chip keeps the reported model until session.model
  confirms, and the not-applied error (or a switch/bind) settles the
  indicator.

Also repairs tests/runner/conftest.py's REAL_CLAUDE_LAUNCH_CATALOG
export, which the previous commit's lint autofix stripped after its
consumers had been verified.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Name the true Default on every picker and pin gateway models

- The web's claude lane keeps the catalog's isDefault marker and all
  Default rows (new-chat select, its summary line, the session gear)
  label through the one shared defaultModelLabel — both harnesses now
  read "Default (X)" where X is the model a bare launch actually runs.
- Provider entries' models map (the existing flat tier keys — opus,
  sonnet, haiku, fable — beside default) now pins the claude alias
  vocabulary: the launch env derives ANTHROPIC_DEFAULT_*_MODEL from the
  declared tiers, models.default pins its own family when that family
  has no explicit key, and the declared ids become the config's
  routable set. Aliases on gateway endpoints resolve inside the
  gateway's own catalog instead of falling back to canonical Anthropic
  ids the gateway rejects.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Make the live model-flow rig trustworthy end to end

The step-8 before/after pass surfaced six defects in the live CUJ
scaffolding itself; with them fixed the suite is 16/16 green against the
implementation branch and red against main in the documented modes:

- Drop tests/conftest.py's inherited OMNIGENT_DISABLE_CATALOG_LOOKUP for
  the rig's spawned server/host — the databricks catalog was empty only
  inside the rig.
- Wait for the post-create navigation with page.wait_for_url: the sync
  Playwright API pumps events only inside playwright calls, so the old
  time.sleep poll read a page.url frozen at the landing route forever.
- Re-read a model dropdown opened during the host's boot-probe warm-up
  until rows (or the settled error) appear.
- Resolve a codex session's private CODEX_HOME through the bridge's own
  state.json (the dir is named by a runner-generated bridge id).
- Row 17: no Escape after a Radix select pick (it closes the whole gear
  modal), pick an effort that differs from the machine's global default,
  and poll for persistence while the browser is still open (the save's
  model leg holds until the pane confirms, so the effort PATCH is sent
  by the page seconds later).
- Snapshot and restore ~/.claude/settings.json around the suite: the
  real /model switches run under the real HOME and Claude persists every
  switch as the developer's global default.

Also: useHostModelOptions retries with backoff so a picker opened during
the boot-probe warm-up fills in when the single-flight probe completes
instead of pinning the transient error until reopened.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Wait out the boot-probe warm-up when reading Default labels

The codex launch pin now resolves through live Unity-Catalog discovery
(seconds on a cold host), so a landing model label read immediately
after opening the config renders the bare sentinel while the web's
retry loop is still filling the catalog. Give the label the same
warm-up wait the dropdown read already has.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Mark a model ask pending before its PATCH, not after

The PATCH is held open while the runner drives and confirms the switch,
so the harness's session.model report usually arrives before the PATCH
resolves. Setting pendingModelChange from the response overwrote the
report's clear and stranded the spinner until the hygiene timer. The ask
is now marked pending up front (and cleared if the PATCH throws); a
store test pins the report-beats-PATCH ordering.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Type full model ids verbatim on unpinned claude sessions

Picking a full-id catalog row (e.g. the appended default
'Opus 4.8 (1M context)') stepped down to '/model opus' — the family
alias resolves to claude's CURRENT generation, silently switching to
Opus 5 instead. The confirm layer caught and surfaced it; the
translation now passes claude-* full ids verbatim on envs with no alias
pins (claude's /model accepts full ids — the probe resolves them the
same way), while pinned envs keep exact-pin-or-fail-loud.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Defer mid-turn model switches instead of failing them

A /model typed during an active turn queues in Claude's composer and
applies when the turn settles — past the 10s confirm window — so the
runner surfaced a false 'was not switched' error for a switch still on
its way, and the injection's short dialog watch could leave the late
confirm dialog parked on the pane.

The confirm loop now answers the switch dialog whenever it renders
inside the window, and a timeout with the pane mid-turn answers success:
a detached watcher keeps answering the late dialog (hint-matched Enter
only, never blind; bounded budget) and the forwarder's verbatim report
settles the picker when the switch lands. An idle-pane timeout — the
genuine swallowed case — still fails loud.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Mark a provider launch pin as the claude catalog default

Default launches on provider-configured shapes pass --model
<config.model> explicitly, so the pin — not the enumeration run's own
model — is what a Default launch actually runs. The gateway-entry shape
(one pinned alias row) went unmarked when the enumeration reported no
default, leaving the picker on a bare 'Default'. Subscription shapes
keep the enumeration-derived marker, and the appended default row only
borrows the probe's printed label when it names the same model.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Merge origin/main and green the CI suite

The merge brought main's error-pill restyle, per-frame create refactor,
and codex live UC discovery alongside this branch's model-flow work.
Fixes to keep every suite green:

- Restore the SimpleNamespace import main added to tests/host/test_connect
  (the merge dropped it → ruff F821 + 2 NameErrors).
- Regenerate openapi.json for the reported_model wording (session.model
  event + llm_model field descriptions).
- Update the smart-routing-create catalog tests to expect the unified
  /model-options route the server now asks first (legacy alias is the
  404 fallback).
- Update the runner pending-catalog test: a provider shape's launch pin
  is appended as the marked default row.
- Adapt row15's e2e to main's collapsed error pill (expand to read the
  detail); move the routed-modal test's seed to llm_model (routed models
  arrive as the harness report now); pick codex landing options by their
  decorated display name (codex options now render display names like
  claude — the design's decorated-rows contract).
- Seed the in-session gear from the session's request only before any
  harness report exists, so a routed session names its model.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Make row15's error-pill expand retry-safe under suite load

The single headline click could land before the disclosure handler was
wired when the suite ran the pill under load, leaving the detail
collapsed and the assertion timing out. Retry the expand until the
detail shows — same as a person clicking again.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Null-guard the native model-id fold so cursor rows don't blank the page

findNativeModelOption's fold fallback (added when the codex catalog fold
moved client-side) called comparableModelId on option.model/id without a
null guard. Cursor picker rows arrive as { id, displayName } with
model === null on the wire (typed model?: string), and the
option.model !== undefined check let null through — comparableModelId(null)
then threw 'Cannot read properties of null (reading trim)' during render,
blanking the whole chat page for any cursor-native session.

comparableModelId is now null-safe (empty fold never matches a real
target) and the fallback rejects null ids/models. Regression test covers
a cursor-shaped options list with null models.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Add a per-harness e2e_ui render-smoke matrix (all native pickers)

One hermetic case per native model-picker harness (claude, codex, cursor,
kiro, opencode, pi): shape a seeded session as that harness with its
realistic model_options — including rows with an explicit model: null
(cursor/kiro/opencode's real wire shape, typed model?: string) and a
hostile null-id row — then render the session, open the gear, and assert
the composer renders, the model control lists the rows, and no uncaught
null-deref fires.

The null-model harness cases carry a non-matching model_override so the
model-id fold actually runs (an exact-id match would return before it),
which is precisely the path that once blanked the page. Validated red on
the pre-fix bundle (cursor/kiro/opencode crash) and green after — the
coverage the earlier per-harness tests missed by using model-omitted
(undefined) rows instead of the null shape.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 16:24:23 -07:00
Mark Tai bb5de03e50 feat(cli): pin databricks workspace login to a per-host profile (#5112)
`omnigent login` to a Databricks-fronted server ran `databricks auth login --host <ws>` with no profile, so each workspace overwrote the shared DEFAULT entry in ~/.databrickscfg. Pass `--profile <first DNS label>` so distinct workspaces keep distinct profiles and DEFAULT is left alone. The OAuth grant stays host-keyed, so token resolution by host is unchanged.

Signed-off-by: Mark Tai <marktai@users.noreply.github.com>
Co-authored-by: Mark Tai <marktai@users.noreply.github.com>
2026-08-20 16:15:31 -07:00
Edwin He fd9a7d94ff fix(web): keep the queued-message strip docked when the background task pill shows (#5127)
The queued-message strip and sub-agent tray dock onto the composer card with a
-mb-4 negative margin that tucks their translucent bottom behind the opaque
card. BackgroundTaskPill was rendered between those trays and the card, so the
tuck landed on the pill's transparent wrapper instead: the pill's height lifted
the "Steer" strip well off the composer and left it visually detached. Render
the pill above the trays so they dock onto the card again and the pill floats
above as its own chip.

Co-authored-by: Isaac

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-20 16:05:24 -07:00
Randy 🌞 8e02d27bc3 feat(web): add new-session keyboard shortcut (#4840)
Route Cmd/Ctrl+N through the same new-session action as the command palette, and make Electron keep that navigation in the focused window instead of opening another app window.

Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 15:52:21 -07:00
Zeyi (Rice) Fan 11bd5fbb5e fix(electron): populate normalized recent servers (#5128)
N/A

- Backfill a previously saved server into the recent-server list after its cold load succeeds, so existing installations do not show an empty landing-page picker.
- Normalize user-entered server URLs to their origin before navigation and persistence, removing paths, queries, and fragments.
- Expand detected Databricks workspaces to the canonical `/omnigent` UI mount shared by the native shells.

ELI5: the desktop now remembers the server it successfully opened and stores the server address instead of whichever page was pasted.

```text
pasted URL -> origin normalization -> workspace discovery -> successful load -> recents
```

- `node --test web/electron/test/url.test.js web/electron/test/main.test.js`
- `uv run --no-sync pytest tests/e2e_ui/desktop/test_setup_connect.py::test_shared_url_module_defaults_scheme_in_browser`
- `pre-commit run --files tests/e2e_ui/desktop/test_setup_connect.py web/electron/README.md web/electron/src/deepLink.js web/electron/src/main.js web/electron/src/url.js web/electron/test/main.test.js web/electron/test/url.test.js web/android/README.md web/android/app/src/main/java/ai/omnigent/android/WorkspaceChromeScript.kt web/ios/Omnigent/WorkspaceURLExpander.swift web/ios/Omnigent/WorkspaceChromeScript.swift`

N/A — behavior-only change with no layout updates.

- [x] Bug fix
- [ ] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [x] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

Regression tests cover cold-load backfilling, origin-only URL normalization, and Databricks expansion to `/omnigent`.

Recent servers now appear reliably in the Electron connection screen, with pasted URLs normalized to the server origin.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 15:47:47 -07:00
Zeyi (Rice) Fan 7560752a23 fix(electron): return Databricks workspace roots to Omnigent (#5125)
## Related issue

Closes #5123

## Summary

- Redirect pinned Databricks workspace roots back to `/omnigent` after full-page auth hand-backs or in-page history navigation.
- Match AWS and Azure workspace domains on dot boundaries, preserve URL metadata, exclude Databricks Apps, and cap redirects to avoid loops.
- Align Electron's workspace mount with the mobile shells and add focused behavior and wiring coverage.

## Test Plan

- `node --test web/electron/test/url.test.js web/electron/test/workspace-root-bounce.test.js web/electron/test/main.test.js web/electron/test/update-main.test.js`
- `node --test web/electron/test/workspace-chrome.test.js`
- `web/node_modules/.bin/oxlint --deny-warnings --report-unused-disable-directives web/electron/src/deepLink.js web/electron/src/main.js web/electron/src/url.js web/electron/src/workspace-root-bounce.js web/electron/test/main.test.js web/electron/test/update-main.test.js web/electron/test/url.test.js web/electron/test/workspace-chrome.test.js web/electron/test/workspace-root-bounce.test.js`
- `web/android/bin/ktlint.sh web/android/app/src/main/java/ai/omnigent/android/WorkspaceChromeScript.kt web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt`
- `web/ios/bin/swift-format.sh format lint --strict web/ios/Omnigent/WorkspaceURLExpander.swift web/ios/Omnigent/WorkspaceChromeScript.swift`

## Demo

N/A — this changes native navigation recovery without adding or changing 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
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Unit tests exercise full and in-page root navigation, domain/origin restrictions, URL preservation, wiring, and loop prevention.

## Changelog

The Electron app now returns you to Omnigent when a Databricks workspace navigation lands at the workspace root.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 15:03:27 -07:00
Thomas Garnier 45424e0739 fix(openai-agents): preserve reasoning item IDs by default (#5065)
Stop breaking tool-call replay on newer OpenAI reasoning models while retaining an explicit compatibility override for legacy providers.

Signed-off-by: Thomas Garnier <mxatone@gmail.com>
2026-08-20 14:57:57 -07:00
Pat Sukprasert 1bc828088c fix(triage): assign an owner to every triaged issue, duplicates included (#5111)
* fix(triage): assign an owner to duplicates left open

The issue-triage workflow classified some issues as duplicates but, with
duplicate auto-closing disabled by default, left them open. The duplicate
branch hit `exit 0` before the assignment step, so those open duplicates
were never routed to an owner — even P0/P1 ones. #4437 and #4484 both landed
this way (P1-high, triaged, unassigned).

Only exit early when the duplicate is actually closed. When closure is
disabled and the issue stays open, fall through to the normal maintainer-
author / least-loaded-owner assignment path so it gets an owner like any
other triaged open issue.

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* fix(triage): assign every triaged issue before closing duplicates

Reworks the earlier fix per review: instead of only routing duplicates
left open, assign an owner to every triaged open issue FIRST, then run
the optional duplicate closure. Closed duplicates now get an owner too,
and because it persists across a close/reopen, a reopened issue no longer
comes back unassigned (triage only fires on `opened`, not reopened).

Drop the now-redundant per-mutation OPEN re-checks on the assignment path;
the closure step keeps its guard against a concurrent human close.

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-21 03:55:07 +07:00
Corey Zumar 9d54826ea5 fix(codex-native): suppress old-model startup prompt (#5116)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 12:41:04 -07:00
Corey Zumar 403095cbf8 fix(web): remember new-session harness settings (#5110)
* fix(web): remember new-session harness settings

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(e2e-ui): cover remembered session settings

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): snapshot only launched harness options

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): remember Codex bypass mode

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 12:16:07 -07:00
Corey Zumar 5baab2fa88 test(e2e-ui): cover native Codex web tools (#5106)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 12:03:25 -07:00
Corey Zumar 2e0098ed1f fix(server): keep idle sessions quiet after runner disconnect (#5113)
* fix(server): only fail a session when a runner drop interrupts a turn

The per-session relay failed a session on any tunnel close, but its stream
stays subscribed while the session is idle. A host going away (sleep,
restart, `omnigent host` stopped) therefore lit a red "connection to the
host dropped" banner over every bound session that had simply finished its
last turn.

Apply the mid-turn gate that `_mark_runner_sessions_offline_impl` already
used, shared as `_MID_TURN_STATUSES`. An idle drop now stays silent — no
status edge and no label clearing, so an earlier genuine failure keeps its
error — and surfaces through liveness, which already drives the reconnect
affordance independently of session status.

The mid-turn check falls back to the durable `live_status` when the
per-replica status cache is cold, so a restart mid-turn does not downgrade
a real interruption to a benign one.

Also fixes a pre-existing race in the tunnel reconciliation test, which
waited on the status flip that precedes the labels it then asserted.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(server): guard the cold-cache live-status read in the relay disconnect path

Address AI review on #4776: the durable fallback read ran unguarded inside
the disconnect handler, so a store error would escape and kill the relay
before either branch published a status. Treat an unreadable or missing row
as interrupted, and gate the scripted test frames behind the collector so
the relayed-event assertions are not scheduler-dependent.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 11:47:33 -07:00
Serena Ruan 5e02fd192a test(web): cover multi-model +N badge in UsageSessionTable (#5114)
Follow-up to #5069, which added the +N model badge to the usage sessions
table with manual verification only. UsageSessionTable had no test file;
this adds render tests for single-model (no badge), multi-model (primary
+ correct +N count), the badge tooltip ordering (non-primary models by
descending cost), and the no-models empty case.

Co-authored-by: Isaac
2026-08-20 11:39:39 -07:00
Corey Zumar 3da0e9f4e0 fix(codex-native): bypass intermittent hook trust gate (#5108)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 11:16:27 -07:00
Manfred Calvo 9a3d4dea54 fix(codex): surface gateway auth failures instead of a silent 600s stall (#4612)
* fix(codex): surface gateway auth failures instead of a silent 600s stall

When the codex SDK head can't authenticate to its model gateway, the codex
CLI retries silently and emits no turn events, so the turn stalled to the 600s
idle watchdog and died with a generic "wedged LLM or tool call" message — even
though the real cause (a 401/403 on the CLI's stderr) was in hand the whole
time.

Parse the CLI's ``unexpected status <code> ... url:`` stderr line and:

- Attribute it: record the parsed cause into the shared idle-watchdog failure
  slot the scaffold already reads, so a stalled turn surfaces the real gateway
  error rather than the generic reason. Adds a general record_transport_failure
  alongside the native forwarder's record_post_failure — no scaffold change.
- Fail fast: an auth-class (401/403) rejection arms a fast-fail once the CLI
  has also exhausted its own retry budget (a final Reconnecting N/N), so the
  turn ends in seconds with a retryable=False ExecutorError naming the status,
  model, and url — instead of riding the full idle watchdog. Both signals are
  tracked independently (either arrival order), so a blip the CLI recovers from
  never kills a healthy turn. The idle wait polls on a shorter interval to act
  on the signal promptly; the 600s warn window and its cadence are unchanged.

Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>

* fix(codex): reserve auth hint for auth failures

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(codex): interrupt fast-failed auth turns

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(codex): cover gateway auth fast-fail e2e

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 11:08:59 -07:00
dosenr 32f0d78ebd fix(tools): paginate agent and session discovery (#4892)
* fix(tools): paginate agent and session discovery

Signed-off-by: Robert Dosen <robert.dosen@gmail.com>

* fix(tools): use cursors for discovery pagination

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(e2e): cover discovery cursor compatibility

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(tools): harden discovery cursor continuation

Signed-off-by: Robert Dosen <robert.dosen@gmail.com>

---------

Signed-off-by: Robert Dosen <robert.dosen@gmail.com>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 11:00:31 -07:00
Corey Zumar fe421c98a7 fix(native): retry preflight forwarder failures (#5104)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 10:59:34 -07:00
Yuan Tang 0e940538c4 fix(web): show all models in usage sessions table (#5069)
When a session uses multiple models, display a +N badge next to the
primary model (highest cost), matching the existing harness pattern.
Hovering the badge shows a tooltip listing the additional models.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-08-20 13:46:00 -04:00
Slađan Ristić 9fc0c382be fix(codex): forward effective context window (#5026)
* fix(codex): forward effective context window

Codex 0.147 moved modelContextWindow outside total usage. Preserve the legacy nested field as a compatibility fallback.

Signed-off-by: Sladan Ristic <sladan.ristic@trigo.at>

* test(codex): cover effective context window end to end

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: Sladan Ristic <sladan.ristic@trigo.at>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 10:23:38 -07:00
Harry Yao 636fb6a774 fix(cli): don't crash host stop on a stale foreign daemon record (#5012)
`omnigent host stop` could crash instead of stopping the host when the
local daemon registry held a stale record for a PID it no longer owns.

`_terminate_daemon` only wrapped `os.kill` in
`contextlib.suppress(ProcessLookupError)`. When a daemon record points at
a PID that has since been reused by another user's process (or the daemon
was started under a different account), `_pid_alive` reports it as alive
— psutil maps the permission failure to `AccessDenied`, which
`_pid_alive` treats as "alive" — so we fall through to `os.kill`, which
raises `PermissionError` (EPERM). That is not a `ProcessLookupError`, so
it propagates and crashes the CLI. `--force` didn't help: the crash
happens before the SIGKILL path.

Add `_signal_daemon_pid(record, sig)`, which signals the recorded PID and
classifies the result:

- `PermissionError` → the PID is not our daemon (stale record). Warn and
  treat the record as stale — drop it instead of killing an unrelated
  process or crashing.
- `ProcessLookupError` → the process already exited; stale record.
- otherwise → the signal was delivered; termination proceeds as before.

`_terminate_daemon` now uses it at both the SIGTERM and SIGKILL sites.
`_pid_alive` is deliberately unchanged — treating a foreign PID as
"alive" is the correct conservative choice for its other callers
(port/daemon reuse detection).

Tickets: OMNI-3473 (primary), OMNI-3196.

Co-authored-by: Isaac

Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-08-20 17:07:12 +00:00
Corey Zumar b86ae8e121 fix(codex): attach local environment to web turns (#5048)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 09:52:20 -07:00
Corey Zumar 0b0fc3f2f9 fix(kubernetes): resume dormant managed hosts (#5046)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 09:50:55 -07:00
Dhruv Gupta ccaa42e926 feat(acp): honor permission_mode so an ACP agent can run unattended (#5056)
Every other harness family lets a spec opt out of per-tool approval prompts via
`executor.config.permission_mode` — claude-sdk (`HARNESS_CLAUDE_SDK_PERMISSION_MODE`),
cursor, claude-native, antigravity-native. Generic ACP honored no such mode, so
it asked on every `session/request_permission` no matter how the agent was
configured. A headless ACP worker — a polly sub-agent, a scheduled task,
`omni run -p` — then parks on a card nobody is watching, and there was no
supported way to lower approval volume.

`AcpAgentConfig` gains `permission_mode`, the wrap reads
`HARNESS_ACP_PERMISSION_MODE`, and both ACP spawn-env builders forward it — the
builtin-row builder too, so the builtin `devin` the picker offers behaves like a
self-registered `acp:devin`.

Deliberately narrow:

- Only `bypassPermissions` waives the card. `auto` is the default when unset, so
  treating it as no-prompt (as cursor does) would silently stop every existing
  ACP agent from asking; an unrecognized value prompts.
- Policy runs in every mode, matching claude-sdk's gate: a DENY still blocks and
  an explicit ASK still prompts. Bypass waives the human gate, not the user's
  rules.
- Bypass answers each request individually and never selects the agent's own
  `switch_bypass` option, which would end the request stream and take the
  agent's tool calls out of policy's view.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-08-20 15:37:02 +00:00
Dhruv Gupta adffcbbc4b feat(acp): offer the agent's own approval scopes on the card (#5053)
An ACP agent can offer several differently-scoped ways to say yes. Devin sends
six options for one shell command — allow once, allow this command class for the
session, always in this project, always everywhere, switch to bypass, reject —
and we collapsed them to Approve/Reject and always answered `allow_once`. That is
the right default, but it means the agent is told "just this once" every time and
re-asks for the same command class forever: a dogfooder hit 50+ prompts in five
minutes on one small task.

Pass the agent's options through to the approval card and reply with the one the
user picked, so a scope the agent already supports ("allow `ls` this session") is
one click and the agent itself stops asking. The card already renders an `answer`
enum as one button per choice and replies with the chosen label, so this is a
backend change only.

- `_decide_permission` returns `(allowed, option_id)`; `_permission_outcome`
  echoes that option only after confirming the agent offered it, and otherwise
  still prefers the narrowest grant. A blanket "always allow" is now only ever
  sent because the user chose it by name.
- The choice card is skipped unless the agent offers a `reject_*` option — it
  replaces the Approve/Reject buttons, so without one the user could not say no —
  and unless every label is distinct, since the reply names the label.
- The adapter installs the richer bridge only on executors that declare the
  attribute, leaving the SDK harnesses on the binary card.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-08-20 08:17:32 -07:00
Hubert ef60c1f874 [OMNI-3742] Enable emojis as project icons (#5099)
* [OMNI-3742] Enable emojis as project icons

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Address feedback

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
2026-08-20 13:24:31 +02:00
Hubert d00c5bd9b1 Remove the permanent Shells tab from the desktop side panel [OMNI-3748] (#5017)
* [OMNI-3748] Remove shells permament tab

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Test fix

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Test fixes, round two

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Address review

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
2026-08-20 13:02:41 +02:00
Hubert b08eb50f2e OMNI-3747-remove-margin-around-shell-in-right-side-panel (#5038)
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
2026-08-20 06:42:43 +00:00
Harry Yao b8fd98ef7b feat(claude-native): propagate an in-terminal /rename to the Omnigent UI (#4104)
* feat(claude-native): propagate an in-terminal /rename to the Omnigent UI

Several in-terminal state changes already mirror to Omnigent so the web
view doesn't go stale — a `/model` switch posts `external_model_change`,
thinking-level switches post `external_reasoning_effort_change`. Session
title was the gap: a `/rename` typed in the Claude Code pane appends a
`custom-title` record to the JSONL transcript, but the reader explicitly
skipped title metadata, so the web session list kept showing the stale
auto-generated first-message title and the two silently diverged.

Read that record and mirror it, following the `external_model_change`
path end to end: the bridge surfaces it on
`TranscriptReadResult.latest_custom_title`, the forwarder posts a new
`external_session_title` event, and the server persists `title` and
publishes a `session.title` SSE event so the sidebar updates without a
reload. A session renamed while it is not the open session converges via
the existing `WS /v1/sessions/updates` diff, which already carries title.

Two behaviors worth calling out:

- The rename is authoritative — it overwrites a title set in the web UI,
  so it uses a plain `update_conversation` rather than the seed-only
  compare-and-swap behind `/auto-title` (which exists to stop an
  *automatic* titler from clobbering a human's name).
- Only the explicit `customTitle` propagates. Claude's generated
  `aiTitle` is ignored because Omnigent runs its own background titler
  and two auto-titlers would fight over one field.

Declined for child sessions, whose `"<agent>:<label>"` titles are
structural and get parsed back apart by the sub-agent tooling. That also
covers the legacy `:closed:` marker, which can only land on a child row —
both writers reject a non-sub-agent title — so no separate guard for it.

The forwarder dedupes by last-posted title. A steady-state poll reads
only past its byte cursor and never re-sees the record, so the dedupe is
there for the cursor rewind / restart path and to make the retry safe to
attempt on every poll; `observed_title` is sticky so a retry survives
polls whose own window carries no rename.

The web-side cache overlay is extracted from `useRenameConversation` into
a shared `overlayTitleIntoCaches` so a terminal rename and a web rename
patch exactly the same caches.

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

* test(e2e-ui): cover terminal /rename streaming to the session sidebar

The E2E UI Required gate flagged that the terminal-originated rename flow
this PR adds had web/** behavior changes with no tests/e2e_ui/** coverage
of the renamed title appearing in the sidebar.

Add a Playwright test that posts the exact external_session_title event
the claude-native forwarder sends to POST /v1/sessions/{id}/events, then
asserts the open tab re-titles the sidebar row live via the session.title
SSE push (no reload, no list refetch) and that the server persisted it.
This exercises the new server publish path and the web session_title
handler end to end, a different seam from the existing PATCH -> WS updates
rename test.

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

---------

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-08-20 06:28:50 +00:00
Pat Sukprasert 1ed6b49671 fix(codex-native): surface standalone turn errors (#5060)
* fix(codex-native): surface standalone turn errors

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* fix(codex-native): dedupe terminal error edges

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-20 14:07:36 +08:00
Pat Sukprasert 33ec51372b fix(runner): reap failed Claude-native sessions (#5040)
* fix(runner): reap failed Claude-native sessions

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* fix(runner): tighten Claude cleanup idempotency

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* fix(runner): ignore vanished Claude timeout targets

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-20 12:01:47 +07:00
Jackson Zheng b2de85a996 fix(web): span the error banner's dashed rule across the chat column (#5070) 2026-08-19 20:45:04 -07:00
Jackson Zheng 044ca76f36 fix(web): stamp assistant bubbles by latest activity, not turn start (#5047) 2026-08-20 01:40:46 +00:00
Edwin He 5520a5e00d perf(server): collapse the per-event access-control reads into one checkout (#4737)
Every event posted to POST /v1/sessions/{id}/events runs an access-control
check before it does anything else, and that check's reads dominate at scale.
For a session whose permissions/metadata are stable for the turn, each streamed
chunk re-pays: resolve_access (session_permissions + users) plus
get_conversation, which internally reads the conversation row + labels in one
pool checkout and the metadata row in a separate one. That is three distinct
checkouts, each with a pool_pre_ping liveness round-trip, per event — the bulk
of the ~16 queries / ~10 checkouts per event measured in #3004.

shared_read_scope() (db/utils.py): a read-only, per-request scope in which
managed_session() reuses one session per engine instead of opening a fresh
checkout on every store call. Wrapped around the access-control burst, so the
permission + conversation + metadata reads collapse to a single checkout
(single-DB); split-DB deployments keep independent checkouts per engine. Write
makers (immediate=True) never participate, so BEGIN IMMEDIATE isolation is
untouched, and the scope is a strict no-op everywhere it is not activated. The
scope is deliberately kept off any path that spans runner network I/O so it
never pins a pooled connection across a multi-second turn.

No ACL semantics change: the same rows are read, just once per request over one
connection. Verified by a new checkout-counting test that the access-control
burst issues exactly one pool checkout, plus unit tests for the scope's reuse,
nesting, write-maker bypass, per-engine keying, and cleanup.

Closes #3004

Co-authored-by: Isaac


Register the scope's session before its SQLite PRAGMAs run: those executes
force the pool checkout, so a failure there must leave the session tracked by
the scope's cleanup — otherwise the checked-out connection would leak.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-19 17:10:16 -07:00
Abhinav Kumar Singh d68aabe03c fix(slack): preserve listener exceptions in logs (#4506) (#4510)
Signed-off-by: Abhinav Kumar Singh <abhinav.kr.singh.2610@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-20 00:10:00 +00:00
Edwin He 179774eb63 perf(permissions): short-lived resolve_access cache on the event path (#4820)
The per-event access-control check re-reads session_permissions + users on
every streamed event, for a session whose grants are stable across the turn —
a large slice of the per-event DB load on POST /v1/sessions/{id}/events.

Cache resolve_access() results with a short TTL (default 5s,
OMNIGENT_ACL_RESOLVE_CACHE_TTL_S; 0 disables). The cache is:

- Positive-only: a no-access result is never cached, so a freshly granted user
  is authorized on their next request rather than after the TTL — sharing takes
  effect immediately, no grant-latency.
- Evicted on every write that changes a decision on this store: grant/revoke
  (per session, which also covers the shared __public__ grant), and
  reassign_user_grants / set_admin (whole cache). A generation counter, sampled
  before the read and re-checked under the lock at store time, stops an
  in-flight reader from re-storing a pre-commit positive on top of that
  eviction — so once such a write returns, this instance serves no stale
  decision.
- Keyed by (conversation_id, user_id): conversation ids are globally unique, so
  eviction needs no workspace context.
- Bounded by an LRU entry cap (default 50k, OMNIGENT_ACL_RESOLVE_CACHE_MAX_ENTRIES)
  so a long-lived replica cannot grow it without limit — resolve_access is on
  the snapshot path too, so one-shot sessions would otherwise linger forever.
- Per store instance (per replica): conversation reads are left untouched and
  always fresh, since conv fields (runner_id, labels) change mid-turn.

Staleness is bounded and one-directional: grants/raises take effect
immediately; only revoke/downgrade can lag, and only up to the TTL. Role
changes made through the separate accounts store (admin demote, user delete)
are not evicted here and propagate within the TTL. Across replicas there is no
invalidation broadcast, so a revoke can be up to the TTL late on other
replicas; that window is LEVEL_EDIT only — the destructive stop/kill path
re-gates LEVEL_OWNER separately, and a deleted session still 404s on its
uncached conversation read.

The authenticated policy-evaluate route's SQL budget drops 11 -> 8: its warm-up
request also warms this cache, so the steady-state route serves the access
decision from memory. That is the production hot path, since the check re-runs
on every event of a turn; a cold process still pays those 3 reads.

Composes with the access-control checkout collapse (#4737): that made the read
burst one checkout; this removes the permission reads entirely on a cache hit.

Part of #3004 (follow-up to the access-control checkout collapse in #4737).

Co-authored-by: Isaac

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-19 15:38:50 -07:00
Yuan Tang 9bc3425913 fix(web): validate custom date range on the usage page (#5058)
* fix(web): validate custom date range on the usage page

Prevent users from selecting future dates or an inverted range
(start after end) in the custom date picker. Both inputs now carry
min/max constraints and auto-correct the paired value on change.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* test(web): add e2e_ui tests for usage page date range validation

Cover the new min/max constraints and auto-correction behavior
with Playwright tests so the E2E UI Required gate passes.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* style: fix formatting in usage page e2e test

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: use timezone-aware datetime in usage page e2e test

Replace date.today() with datetime.now(tz=timezone.utc).date() to
satisfy the ruff DTZ011 rule.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

---------

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-08-19 22:18:39 +00:00
Yuan Tang 752d3eb3d6 feat(web): show sub-agent harnesses in usage sessions table (#4996) 2026-08-19 17:42:51 -04:00
Tomu Hirata 4ac3dfc5be test(telemetry): add coverage for PolicyRegisteredEvent (#4338)
PolicyRegisteredEvent was defined and called in both policy routes but
had zero test coverage.  Add three test groups:

1. _build_record serialisation (tests/test_telemetry.py): verify that
   admin-scope (session_id=None) and session-scope events produce the
   correct wire format — promoted top-level fields vs. params content.

2. Default-policy route emit (tests/server/routes/test_default_policies.py):
   POST /v1/policies fires exactly one PolicyRegisteredEvent with
   scope='admin'; a 409 conflict does not emit.

3. Session-policy route emit (tests/server/routes/test_session_policies_crud.py):
   POST /v1/sessions/{id}/policies fires exactly one PolicyRegisteredEvent
   with scope='session' and the correct session_id; a 409 conflict does not emit.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-20 06:22:20 +09:00
Dhruv Gupta 741f2d29e4 fix(host,runner): reconnect host + all sessions promptly on laptop wake (#5054)
On macOS sleep the OS freezes every Omnigent process and drops the network,
killing the host control-channel WebSocket and every runner (session) tunnel.
On wake nothing reconnected promptly: the only liveness signal was the
websockets keepalive ping (30s interval / 90s timeout), so a half-open
post-sleep socket took up to ~120s to be noticed — and the server had already
deregistered the host, so the desktop app showed "disconnected" that whole
time while the terminal (a one-time startup banner) still read "connected".

Add omnigent/suspend_watch.py: watch_for_resume() detects a resume by polling
a short interval and comparing wall-clock vs monotonic-clock drift. The
monotonic clock freezes during sleep on macOS/Linux while the realtime clock
keeps counting, so a resume shows up as a large divergence; a merely-blocked
event loop advances both equally, so this never false-fires on CPU stalls.
Uses time.monotonic (never loop.time, which under uvloop includes sleep on
macOS and would zero out the divergence).

Wire it into both reconnect loops:
- Host (connect.py): a watcher aborts the live tunnel (ws.transport.abort())
  on wake and flags a prompt reconnect, so run() reattaches at the base
  backoff instead of the escalated one (required on a loopback server, where
  an abrupt close is not auto-classified as a benign recycle).
- Runner (serve.py): a per-connection watcher aborts the tunnel and notes the
  resume so serve_tunnel reconnects promptly; each session self-heals on its
  own event loop, so no host orchestration is needed.

Result: opening the laptop reconnects the host and all its sessions within
~5s instead of up to ~2 minutes. Windows degrades to todays keepalive
behavior (its monotonic clock counts suspend) with no regression.

Tests: unit tests for the detector (fires once on divergence, never on a
blocked loop, survives a raising callback) plus host and runner integration
tests (a simulated wake aborts the live tunnel and forces a prompt reconnect).

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-08-19 14:04:09 -07:00
Jeremiah lu b551f669d6 fix(openai-agents): wrap string assistant content for the chat converter (#4824)
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>
2026-08-20 05:41:00 +09:00
Yuan Tang ddc5f6ee60 chore(k8s): Address ArgoCD overlay review follow-ups and PR #4744 comments (#4982)
* 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>
2026-08-19 12:38:39 -07:00
Dhruv Gupta 473941e322 fix(acp): resolve the tool identity on a bare permission request (#5050)
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>
2026-08-19 12:31:39 -07:00
Corey Zumar 1f575ba8de fix(docker): honor configured execution timeout (#5016)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 11:27:43 -07:00
Corey Zumar 8f63f3271b fix: preserve managed host logs on relaunch (#5042)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 11:17:01 -07:00
Edwin He 9df23f7aeb chore(ucode): pin OSS ucode to a fixed commit (#5043)
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>
2026-08-19 18:08:48 +00:00
Mark Tai c6d1f7d5e0 feat(web): resume imported / host-less sessions from the web (#4905)
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>
2026-08-19 10:16:18 -07:00
Corey Zumar 05eaa253d1 fix(host): retry Databricks auth refresh (#5014)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 10:12:39 -07:00
Evelyn Hur 699809de6e Show actual server target in host-daemon conflict error (#4821)
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>
2026-08-20 01:11:47 +08:00
Hubert 4f05fbd0ac [OMNI-2359] [OMNI-2350] Show the task tracker inside the chat (#5036)
* 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>
2026-08-19 17:11:45 +02:00
Hubert 537620909c [OMNI-3751] Change document view mode toggle to dropdown (#5015)
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-08-19 14:47:33 +02:00
Corey Zumar ed9369474f fix(web): stop rendering single-dollar spans as LaTeX math (#5013)
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>
2026-08-19 01:52:35 -07:00
omnigent-ci[bot] 1fceeeb754 Bump version to 0.11.0.dev0 (#4989)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 05:44:29 +00:00
omnigent-ci[bot] 71a3437168 docs(changelog): record v0.10.0 (#4991)
* 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>
2026-08-19 04:33:43 +00:00
Daniel Lok 86726e0ab5 fix(web): keep the Working shimmer lit while background tasks run (#4906)
* 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
2026-08-19 08:18:47 +08:00
Zeyi (Rice) Fan 03c7907966 fix(host): keep capability probes out of tunnel handshake (#4769)
## 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>
2026-08-18 17:18:32 -07:00
Tomu Hirata adcf83ccb6 feat(pi): Add searchable model picker for new sessions with Databricks Unity AI Gateway OAuth (#4961)
* feat(pi): add searchable start model picker

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* fix(pi): harden model picker compatibility

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* refactor(pi): simplify model picker filtering

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

---------

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-08-19 00:02:35 +00:00
Mark Tai 70ee54bdba feat(cli): gate naked omni invocations behind a wrapper guard (#4766)
* 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>
2026-08-18 15:38:07 -07:00
Dhruv Gupta 036e0b9d29 fix(electron): trigger sign-in from "Run on this machine" instead of looping on "No hosts" (#4972)
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>
2026-08-18 12:03:36 -07:00
Dhruv Gupta fb579783ce fix(web): make the Working… status pin opaque in dark mode (#4962)
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>
2026-08-18 11:23:22 -07:00
Yuan Tang fabdc7d25b feat(deploy): add ArgoCD overlay for kubernetes sandbox provider (#4788)
* 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>
2026-08-18 10:59:21 -07:00
Hubert ddfa872809 OMNI-3743: Add session name and project information to chat title bar, fix sizing (#4940)
* OMNI-3743: Add session name and project information to chat title bar

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Improvement

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Another fix

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Native app fixes

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(e2e-ui): regenerate visual baselines

* test fixes

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Post-review fixes

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* restore native back

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-08-18 17:43:18 +00:00
Anton Nekipelov 21f71ddfdd fix(runner): retry tunnel 401/403 on an already-connected runner (#4957)
#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>
2026-08-18 17:34:46 +00:00
Dhruv Gupta 1fc4b283b8 test(host): close the cancel race in the midspawn leak test (#4971)
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>
2026-08-18 10:34:33 -07:00
Edwin He 83b7ff409f fix(web): back off silent sticky-apply PATCHes when the backend errors (#4777)
* 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>
2026-08-18 10:16:26 -07:00
Yuan Tang a447db22cb feat(k8s): replace bare Pods with Jobs for automatic failover (#4744)
* 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>
2026-08-18 16:09:12 +00:00
Dhruv Gupta ce00d35f0d fix(harnesses): let a configured acp: agent win over a same-slug builtin row (#4927)
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>
2026-08-18 08:41:56 -07:00
Jackson Zheng 8aaf72c91a feat(web): restyle chat error banner as a centered pill (#4931) 2026-08-18 07:33:23 +00:00
Edwin He 65021dc1e8 fix(databricks): resolve harness launch models from the workspace (#4915)
* 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>
2026-08-18 04:32:05 +00:00
Dhruv Gupta 6d277c4fc0 docs(acp): correct the env-var claim on builtin ACP CLI rows (#4925)
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>
2026-08-17 17:36:01 -07:00
Dhruv Gupta 5de71a1877 feat(acp): Devin as a builtin harness + catalog-derived picker identity (#4920)
* 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>
2026-08-17 17:17:29 -07:00
Dhruv Gupta fcf5a902af feat(server): seed configured ACP agents into the New Chat picker (#4909)
* 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>
2026-08-17 21:11:36 +00:00
Hubert ba3692130d [OMNI-2843 fix] Fix overlaying toolbar icons (#4897)
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
2026-08-17 14:13:06 +02:00
Hubert 57df36a1ee feat(web): show background tasks as a composer pill, not the working shimmer (#4893)
* 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>
2026-08-17 11:55:02 +02:00
Tomu Hirata fc0e2e99c6 perf: eliminate redundant DB queries in session create and host wake paths (#4809)
- 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>
2026-08-17 01:36:13 +00:00
Tomu Hirata c2439c6fd7 fix(windows): pass Windows process essentials through harness env filters (#4886)
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>
2026-08-17 01:23:12 +00:00
aminekaabachi 901aa8d12a feat(web): white-label UI branding via config.yaml (#2857) 2026-08-15 15:35:11 -07:00
Tomu Hirata 2aba5079d4 feat(bench): add CLI startup latency benchmark (#4793)
* 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>
2026-08-15 12:16:06 +00:00
Jackson Zheng dc10a22147 OMNI-3247: Update in-chat error patterns (#4787) 2026-08-14 21:50:12 -07:00
Zeyi (Rice) Fan 08b20956f2 fix(cli): honor isolated Omnigent state directories (#4822)
## 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>
2026-08-15 00:00:14 +00:00
Edwin He 39c986cb06 feat(desktop): build macOS app for Intel + Apple Silicon (#4772)
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>
2026-08-14 16:56:02 -07:00
Edwin He c8c4f81826 ci(electron): remove the redundant manual Electron Build workflow (#4771)
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>
2026-08-14 15:47:32 -07:00
Zeyi (Rice) Fan 6c2daae4a9 fix(codex): pin shadowed config provider on resume (#4818)
## Related issue

N/A — reported and reproduced locally.

## Summary

- Pin Codex's detected `config.toml` provider when an explicit, non-default same-name Omnigent entry shadows ambient default synthesis.
- Resolve the provider once during native launch so rollout metadata, app-server, and remote TUI use the same immutable selection.
- Preserve spec, explicit-default, global-auth, subscription, and dismissed-provider precedence.

ELI5: if Codex is configured to use a gateway but Omnigent's matching provider entry is not marked default, a resumed conversation now follows Codex's actual gateway instead of falling back to unauthenticated OpenAI.

```text
Codex config detection ──► resolved native launch ──► resume rollout/TUI
      Databricks                  Databricks                 Databricks
```

## Test Plan

- `uv run --frozen pytest tests/test_native_codex_provider.py -k 'config_provider_shadowed_by_nondefault_explicit_entry_still_pins or shadowed_config_detection_uses_active_profile_provider or resolve_native_codex_launch_undismissed_config_provider_routes_via_pin or resolve_native_codex_launch_dismissed_config_provider_pins_openai'`
- `uv run --frozen pytest tests/test_codex_native.py -k 'resolve_native_codex_launch_no_provider_sets_login_fallback_summary or resolve_native_codex_launch_databricks_provider_sets_summary'`
- `uv run --frozen ruff check omnigent/codex_native_app_server.py tests/test_native_codex_provider.py tests/test_codex_native.py`
- `uv run --frozen ruff format --check omnigent/codex_native_app_server.py tests/test_native_codex_provider.py tests/test_codex_native.py`
- `git diff --check`

## Demo

N/A — non-visual backend 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
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The new tests reproduce the shadowed non-default provider state and verify active Codex profile selection. Existing tests cover dismissed providers, ordinary detected providers, explicit defaults, and no-provider summaries.

## Changelog

Resumed Codex conversations now keep using the provider selected in Codex configuration.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-14 22:17:47 +00:00
Zeyi (Rice) Fan 73abf26b8e fix(web): quote server URLs in generated commands (#4817)
## Related issue

https://linear.app/omnigent/issue/OMNI-3481/quote-server-urls-in-ui-connection-commands

## Summary

- Prevent shells from interpreting query strings and other metacharacters in server URLs shown by the web UI.
- Quote server URLs as single POSIX shell arguments across host, Lakebox, reconnect, and resume commands.
- Cover command rendering and embedded quote escaping with focused tests.

## Test Plan

- `cd web && npm test -- src/lib/shell.test.ts src/shell/ReconnectSessionDialog.test.tsx`
- `cd web && npm test -- src/shell/NewChatDialog.test.tsx -t "quotes server URLs"`
- `cd web && npm run type-check`
- `cd web && ./node_modules/.bin/oxlint --deny-warnings --report-unused-disable-directives src/lib/shell.ts src/lib/shell.test.ts src/shell/NewChatDialog.tsx src/shell/NewChatDialog.test.tsx src/shell/ReconnectSessionDialog.tsx src/shell/ReconnectSessionDialog.test.tsx`
- `cd web && npm exec -- prettier --check src/lib/shell.ts src/lib/shell.test.ts src/shell/NewChatDialog.tsx src/shell/NewChatDialog.test.tsx src/shell/ReconnectSessionDialog.tsx src/shell/ReconnectSessionDialog.test.tsx`

## Demo

Before:

```sh
omni host --server https://example.com/api?profile=dev&glob=*
```

After:

```sh
omni host --server 'https://example.com/api?profile=dev&glob=*'
```

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] 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

Unit coverage verifies shell quoting directly and rendering through both connection-command UI paths.

## Changelog

Server URLs in copyable connection and reconnect commands are now safely quoted.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-14 21:05:27 +00:00
Zeyi (Rice) Fan b9d53f0a96 feat(server): add deployment-wide release feature flags (#4775)
## 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>
2026-08-14 10:57:20 -07:00
Zeyi (Rice) Fan 8f194d6f9d test(antigravity): wait for quiescence poll progress (#4778)
## 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>
2026-08-14 10:42:43 -07:00
Tomu Hirata 204e99d5c6 fix(deps): resolve protobuf gencode/runtime mismatch in antigravity extra (#4795)
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>
2026-08-14 09:25:20 +00:00
Yuan Tang d41f491e37 feat(runner): auto-assign structured names to subagents (#4489)
* 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>
2026-08-14 06:48:21 +00:00
Jackson Zheng 4e13ff5b82 fix(web): keep chat above growing composer (#4767) 2026-08-13 22:33:11 -07:00
Daniel Lok bee2b7518e feat(web): let bulk session delete clean up worktree branches (#4715)
* 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>
2026-08-14 11:01:27 +08:00
Zeyi (Rice) Fan 849af0674f fix(omnidev): forward custom Vite port with pnpm (#4770)
## 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>
2026-08-14 01:16:26 +00:00
Zeyi (Rice) Fan 244ded1ed9 chore(deps): move development tooling to internal groups (#4626)
## 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>
2026-08-13 18:00:38 -07:00
Jackson Zheng e811f35f23 fix(sessions): keep filter control visible (#4764) 2026-08-13 17:25:18 -07:00
Corey Zumar c6b627cfc6 perf(terminal): attach web terminals over loopback when the runner is local (#4763)
* 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>
2026-08-13 16:42:54 -07:00
Corey Zumar c4dd03c47c fix(web): treat an answered question card as a user turn, and rebuild it on reload (#4760)
* 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>
2026-08-13 14:29:43 -07:00
Corey Zumar c118266e59 ci(bench): host-session benchmark workflow + job-summary results matrix (#4758)
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>
2026-08-13 14:24:49 -07:00
Jackson Zheng d0c4317757 fix(web): match Inbox count badge colors to active session styling (OMNI-2957) (#4714) 2026-08-13 14:24:34 -07:00
Corey Zumar 35689fc2a6 fix(web): self-heal the chat stream when it dies silently (#4750)
* 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>
2026-08-13 13:40:44 -07:00
Corey Zumar f79e196428 fix(claude-native): reclaim an occupied composer before injecting web-UI messages (#4751)
* 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>
2026-08-13 13:30:29 -07:00
Corey Zumar f75c07ba56 perf(host): cut host-launched session-create latency (#4752)
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>
2026-08-13 13:21:04 -07:00
Corey Zumar bc3dc80200 perf(claude-native): Improve performance of claude native terminal typing, text streaming, etc. (#4582)
* 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>
2026-08-13 13:11:46 -07:00
Corey Zumar 75b6e71b18 fix(web): name the harness on native approval cards (#4735)
* 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>
2026-08-13 12:45:38 -07:00
Corey Zumar 84d1753c84 fix(web): don't flash the picker's Up tooltip when it opens (#4742)
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>
2026-08-13 10:55:40 -07:00
Tomu Hirata ce6dba9c88 fix(opencode-native): accept 1.18.x — bump version gate to <1.19.0 (#4725)
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>
2026-08-13 13:57:13 +00:00
Yuan Tang 10e5cf6059 fix(web): don't re-stamp replayed pending messages at promotion time (#4722)
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>
2026-08-13 09:55:44 -04:00
Abhinav Kumar Singh 8f73e26a74 fix(sdk): honor client timeout (#4505) (#4509)
Signed-off-by: Abhinav Kumar Singh <abhinav.kr.singh.2610@gmail.com>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-13 13:42:02 +00:00
Tomu Hirata a31e3f67ac test(e2e): compatibility smoke tests + CI integration for server/runner cross-version (#4717)
* 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>
2026-08-13 22:12:09 +09:00
Pietro Fariello a736d3aeed fix: keep Claude ToolSearch in SDK base tools (#3134)
* fix: keep Claude ToolSearch in SDK base tools

Signed-off-by: ptrfariello <pietro.fariello@syrto.ai>

* fix: force Claude SDK tool search

Signed-off-by: ptrfariello <pietro.fariello@syrto.ai>

---------

Signed-off-by: ptrfariello <pietro.fariello@syrto.ai>
2026-08-13 09:27:54 +00:00
Yi Lyu 32f58f1e16 fix(pi-native): carry catalog token limits into the interactive model list (#4178)
``_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>
2026-08-13 09:25:59 +00:00
Shekhar Kadyan 321a766e59 fix(spec): expand ${VAR} in the MCP url field (#4398)
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>
2026-08-13 09:12:02 +00:00
Andrew Reid 6377f3fbd9 fix(runner): recover from turn-context desync and fail-closed guardrails (#1026) (#1077)
* 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>
2026-08-13 08:22:05 +00:00
Daniel Lok f55001af0f feat(web): keep conversation streams open in the background (#4113)
* 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>
2026-08-13 15:11:37 +08:00
Yuan Tang d32440a1f4 feat(chat): show timestamps on chat message bubbles (#4372) 2026-08-12 23:41:36 -07:00
Tomu Hirata 31b9c81664 fix(claude-sdk): emit CompactionInProgressEvent at PreCompact signal (#4716)
* 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>
2026-08-13 06:33:35 +00:00
Edwin He 0f1afc3502 feat(web): let a shared-with viewer leave a session (#4571)
* 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>
2026-08-13 06:00:27 +00:00
Edwin He 6bb128c804 feat(web): automatic dev sharding + bare workspace URL for npm run dev (#4713)
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>
2026-08-13 04:46:06 +00:00
Edwin He 9e5b8741d2 feat(cli): route host-scoped requests to the replica holding the host's tunnel (#4185)
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>
2026-08-12 21:06:44 -07:00
June74 0d073b3ab4 fix(deps): declare tzdata on Windows so the server can start (#4475)
* 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>
2026-08-13 02:57:48 +00:00
Yuan Tang d720d27624 fix(web): add bulk move-to-project action in sidebar selection mode (#4452)
* 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>
2026-08-12 21:37:45 -04:00
Edwin He b0becdc002 feat: shard managed-server host + session traffic by host_id (server + web) (#2037)
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>
2026-08-13 01:36:44 +00:00
Yuan Tang 77eae57b35 feat(web): add Usage page with session cost tracking (#4673)
* 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>
2026-08-13 01:30:45 +00:00
Lee moon soo 9066b64397 perf(server): reuse conversation for runner routing (#4695)
* perf(server): reuse conversation for runner routing

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

* test(server): accept preloaded conversation in runner fakes

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

---------

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>
2026-08-13 01:03:03 +00:00
Edwin He 431b9fea37 fix(cli): omni resume lists only the caller's own sessions (#4709)
`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>
2026-08-12 17:52:11 -07:00
Dhruv Gupta c06ac82ce0 fix(acp): report cached-read tokens instead of dropping them (#4699)
`_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>
2026-08-13 00:42:47 +00:00
Edwin He a71df6c13a fix(host): generate host_id when config.yaml provides only a host name (#4708)
* 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>
2026-08-12 17:39:51 -07:00
Dhruv Gupta 23279f901d feat(acp): apply /model to a live session without losing the transcript (#4703)
* 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>
2026-08-13 00:35:15 +00:00
Dhruv Gupta 59bedc1fac fix(host): keep the session workspace off the runner's sys.path (OMNI-2963) (#4688)
* 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>
2026-08-12 17:07:21 -07:00
Dhruv Gupta 50e9254a2a fix(acp): resolve acp:<slug> client-side for remote server compatibility (#4702)
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>
2026-08-12 17:03:50 -07:00
Dhruv Gupta 3acb7131fa feat(setup): show builtin ACP CLI harnesses in omni setup (#4700)
`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>
2026-08-12 23:50:56 +00:00
Edwin He aff9f1f263 Adopt a slow /v1/info probe instead of pinning the offline fallback (#4694)
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>
2026-08-12 16:47:28 -07:00
Yuan Tang 9b5c790126 fix(web): redirect to home when archiving the active session (#4671)
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>
2026-08-12 23:22:10 +00:00
Mark Tai d03b06f16d feat(server): Offer multiple sandbox providers at once (#4006)
* 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>
2026-08-12 15:44:11 -07:00
Dhruv Gupta 1140399082 Promote a sub-agent by forking it to top level (#4584)
* 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>
2026-08-12 15:43:01 -07:00
Jackson Zheng 91cf401d40 Fix claude native replay for screenshots from MCP results (#4659) 2026-08-12 14:24:13 -07:00
Dhruv Gupta 4dab78942c fix(spec): keep the acp:<slug> agent id through spec translation (#4689)
`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>
2026-08-12 20:45:31 +00:00
Dhruv Gupta 3bbcb7609f fix(electron): normalize legacy host_ prefix so "Run on this machine" auto-selects (#4691)
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>
2026-08-12 13:29:07 -07:00
Dhruv Gupta 3f9d0a3212 fix(web): open agent file links in the FileViewer instead of 404ing (#4644) 2026-08-12 18:10:11 +00:00
Andrew Reid 0bea9873e6 fix(runner): root sub-agent sessions at their own bundle dir (#3567)
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>
2026-08-12 09:22:28 +00:00
Tomu Hirata 87990c09f3 fix(slack): wait for ack deletion before asserting StreamInterruptedError text (#4658)
_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>
2026-08-12 08:41:36 +00:00
Daniel Lok 31b2906c3c fix(claude-native): supersede stranded in-flight text on later commits (#4656)
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
2026-08-12 15:32:35 +08:00
Tomu Hirata 34ad0be8fa perf: skip redundant session GET and cache token mint on omni startup (#4652)
* 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>
2026-08-12 07:19:14 +00:00
Jackson Zheng 79d0f9b628 fix(web): wrap long unbroken chat text/inline-code instead of overflowing (#4651)
* 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
732 changed files with 69877 additions and 11096 deletions
@@ -146,7 +146,7 @@ streaming, harness.
```bash
# Unit tests (use --frozen; the cwsandbox extra is unsatisfiable on public PyPI here)
uv run --frozen --extra dev python -m pytest \
uv run --frozen --group test python -m pytest \
tests/inner/test_antigravity_executor.py \
tests/inner/test_antigravity_harness.py \
tests/runtime/test_antigravity_spawn_env.py \
+1 -1
View File
@@ -157,7 +157,7 @@ already has complementary CUJ coverage — use both:
- **Deeper end-to-end journeys → `tests/e2e/test_journey_*.py`** (first session
to code, resume/disconnect, fork/explore, file upload, collaboration, …).
Run a slice with the project's gated runner, e.g.
`uv run --frozen --extra dev python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
`uv run --frozen --group test python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
- **Reusable PTY helpers** live in `tests/e2e/omnigent/_pexpect_harness.py`
(`spawn_omnigent_run`, `wait_for_ready`, `submit_prompt`, `await_turn_complete`,
`clean_exit`) and the snapshot comparator in `tests/e2e/omnigent/_snapshot.py`
+3 -3
View File
@@ -20,8 +20,8 @@ the unit tests.
1. **You're on the branch you want to test.** The copilot harness is an
optional extra — install it (without disturbing other extras) with
`uv sync --frozen --extra dev --extra copilot`. NB: a bare
`uv run --frozen --extra dev` re-syncs the venv and **prunes** the copilot
`uv sync --frozen --group test --extra copilot`. NB: a bare
`uv run --frozen --group test` re-syncs the venv and **prunes** the copilot
SDK; for live testing call `.venv/bin/omni` / `.venv/bin/python` directly and
avoid `uv run` mid-session.
2. **The SDK is installed:**
@@ -169,7 +169,7 @@ final answer lands server-side — read it over the AP API
- **Spawn env:** `_build_copilot_spawn_env` in `omnigent/runtime/workflow.py`
```bash
uv run --frozen --extra dev python -m pytest \
uv run --frozen --group test python -m pytest \
tests/inner/test_copilot_executor.py \
tests/inner/test_copilot_harness.py \
tests/runtime/test_copilot_spawn_env.py \
+2 -2
View File
@@ -128,12 +128,12 @@ that works, the full stack is good: key, egress, bridge, harness.
```bash
# Unit tests (use --frozen; the cwsandbox extra is unsatisfiable on public PyPI here)
uv run --frozen --extra dev python -m pytest \
uv run --frozen --group test python -m pytest \
tests/inner/test_cursor_executor.py \
tests/runtime/test_cursor_spawn_env.py \
tests/onboarding/test_cursor_auth.py -q
# Gated end-to-end harness test
uv run --frozen --extra dev python -m pytest tests/e2e/omnigent/test_per_harness_cursor.py -q
uv run --frozen --group test python -m pytest tests/e2e/omnigent/test_per_harness_cursor.py -q
```
## Bug-bash (fan out)
+1 -1
View File
@@ -203,7 +203,7 @@ side effects.
```bash
# Existing pytest e2e for polly (mock-LLM) — complementary to this skill:
uv run --frozen --extra dev python -m pytest \
uv run --frozen --group test python -m pytest \
tests/e2e/test_polly_e2e.py \
tests/e2e/test_polly_cost_advisor_e2e.py \
tests/e2e/test_polly_subagent_model_e2e.py -q
+1 -1
View File
@@ -19,7 +19,7 @@ micro-benchmarks (not concurrency), that is a different tool: `dev/benchmarks/`.
## 1. Ensure deps (repo checkout)
```bash
pip install -e '.[loadtest,dev,agents-sdk]' # or: uv sync --extra loadtest --extra dev --extra agents-sdk
uv sync --extra loadtest --extra agents-sdk
```
Run with that same interpreter (e.g. `.venv/bin/python`), from the repo root.
+2 -2
View File
@@ -2,8 +2,8 @@
web/electron/icons/AppIcon.icon/** binary -merge
# Protobuf bindings regenerated by scripts/gen_routing_pb2.py from the .proto
# schema. Mark them generated so review/code-quality tooling skips them (ruff
# and mypy already exclude them in pyproject.toml); the protoc output isn't
# schema. Mark them generated so review/code-quality tooling skips them (Ruff
# excludes them and Pyrefly ignores generated-code errors); the protoc output isn't
# hand-editable, so its unused-import/global artifacts are expected.
omnigent/api/**/*_pb2.py linguist-generated=true
omnigent/api/**/*_pb2.pyi linguist-generated=true
+143
View File
@@ -0,0 +1,143 @@
name: "Run e2e compat smoke tests"
description: >
Run @pytest.mark.compat_smoke tests from tests/e2e/ in one configuration:
either the server or the runner subprocess is pinned to an older released
build while the other side stays on the checked-out code. Exactly one of
server_version / runner_version must be set.
Reuses the same install steps as .github/actions/e2e-run so the two
never drift on Python/uv/binary-dep setup. Unlike e2e-run this action
is not sharded and runs only the compat_smoke marker, keeping wall-clock
time under ~15 minutes.
inputs:
server_version:
description: >
Release tag for the OLD server build (e.g. v0.9.0).
Set this for Config 1 (new runner, old server). Leave empty for Config 2.
required: false
default: ""
runner_version:
description: >
Release tag for the OLD runner build (e.g. v0.9.0).
Set this for Config 2 (new server, old runner). Leave empty for Config 1.
required: false
default: ""
artifact_suffix:
description: >
Appended to uploaded-artifact names to keep them unique across jobs
(e.g. "-config1"). Default empty — fine for single-run cases.
required: false
default: ""
runs:
using: composite
steps:
- name: Configure environment
shell: bash
run: |
{
echo "OMNIGENT_SKIP_WEB_UI=true"
echo "ANTHROPIC_API_KEY="
echo "OPENAI_API_KEY="
echo "CODEX="
echo "CLAUDE_CODE="
} >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
with:
enable-cache: true
- name: Cache virtualenv
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and test dependencies
shell: bash
run: uv sync --locked --extra all --group test
- name: Install binary dependencies
working-directory: .github/ci-deps
shell: bash
run: |
sudo apt-get install -y tmux bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
npm install --ignore-scripts
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: "Build pinned old server (Config 2: new runner, old server)"
if: ${{ inputs.server_version != '' }}
shell: bash
env:
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
run: |
tag="$SERVER_VERSION_INPUT"
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
echo "Invalid server_version: '$tag'" >&2; exit 1
fi
src="$RUNNER_TEMP/server-src"
venv="$RUNNER_TEMP/server-env"
git worktree add --detach "$src" "$tag"
uv venv --python 3.12 "$venv"
uv pip install --python "$venv/bin/python" \
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
"$venv/bin/omnigent" --version
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
- name: "Build pinned old runner/host (Config 1: new server, old runner)"
if: ${{ inputs.runner_version != '' }}
shell: bash
env:
RUNNER_VERSION_INPUT: ${{ inputs.runner_version }}
run: |
tag="$RUNNER_VERSION_INPUT"
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
echo "Invalid runner_version: '$tag'" >&2; exit 1
fi
src="$RUNNER_TEMP/runner-src"
venv="$RUNNER_TEMP/runner-env"
git worktree add --detach "$src" "$tag"
uv venv --python 3.12 "$venv"
uv pip install --python "$venv/bin/python" \
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
"$venv/bin/omnigent" --version
echo "OMNIGENT_COMPAT_RUNNER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
echo "OMNIGENT_COMPAT_RUNNER_VERSION=${tag#v}" >> "$GITHUB_ENV"
- name: Run compat smoke tests
shell: bash
env:
E2E_TMP_BASE: /tmp/omnigent-compat-smoke-${{ github.run_id }}
run: |
mkdir -p "$E2E_TMP_BASE"
uv run pytest tests/e2e/ \
-m compat_smoke \
-v --tb=long --showlocals --log-level=INFO \
--timeout=120 \
--timeout-method=thread \
--basetemp="$E2E_TMP_BASE" \
--junitxml="$E2E_TMP_BASE/junit.xml"
- name: Upload logs on failure
if: ${{ failure() || cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: compat-smoke-logs-${{ github.run_id }}${{ inputs.artifact_suffix }}
path: |
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/server.log
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/runner.log
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/.omnigent/logs/**/*.log
/tmp/omnigent-compat-smoke-${{ github.run_id }}/junit.xml
retention-days: 7
if-no-files-found: warn
include-hidden-files: true
@@ -0,0 +1,227 @@
name: "Run UI compat tests (smoke or full suite)"
description: >
Run tests/e2e_ui/ in one of two cross-version configurations:
Config A — new SPA + new runner, old server (server_version set):
The server subprocess is pinned to the released tag while the SPA is
built from HEAD. Tests the common deploy ordering where the server
lags behind the frontend.
Config B — old SPA, new server + new runner (ui_version set):
The SPA is built from the released tag's web/ source and the HEAD
server is pointed at it via OMNIGENT_WEB_UI_DIST. Tests cached-SPA
scenarios where a user's browser has an older bundle after a server
upgrade.
Exactly one of server_version / ui_version must be set.
Two run modes:
- full_suite=false (default): run only @pytest.mark.compat_smoke tests.
- full_suite=true: run the complete tests/e2e_ui/ suite, sharded.
inputs:
server_version:
description: >
Release tag for the OLD server (e.g. v0.9.0). SPA and runner stay
on HEAD. Mutually exclusive with ui_version.
required: false
default: ""
ui_version:
description: >
Release tag whose web/ source is used to build the OLD SPA (e.g.
v0.9.0). Server and runner stay on HEAD; OMNIGENT_WEB_UI_DIST is
set to the old built bundle. Mutually exclusive with server_version.
required: false
default: ""
full_suite:
description: >
"true" = run the full tests/e2e_ui/ suite with sharding (overnight matrix).
"false" (default) = run only @pytest.mark.compat_smoke tests (PR gate).
required: false
default: "false"
shard_id:
description: "0-based shard index (only used when full_suite=true)."
required: false
default: "0"
num_shards:
description: "Total shard count (only used when full_suite=true)."
required: false
default: "1"
artifact_suffix:
description: >
Appended to uploaded-artifact names (e.g. "-ui-config"). Default empty.
required: false
default: ""
runs:
using: composite
steps:
- name: Configure environment
shell: bash
run: |
{
echo "OMNIGENT_SKIP_WEB_UI=true"
echo "ANTHROPIC_API_KEY="
echo "OPENAI_API_KEY="
echo "CODEX="
echo "CLAUDE_CODE="
} >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version-file: ".python-version"
- name: Set up pnpm + Node
uses: ./.github/actions/setup-pnpm
- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
with:
enable-cache: true
- name: Cache virtualenv
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and test dependencies
shell: bash
run: uv sync --locked --extra all --group test
- name: Install bubblewrap and tmux
shell: bash
run: |
sudo apt-get install -y bubblewrap tmux
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
shell: bash
run: uv run playwright install --with-deps chromium
- name: Build HEAD SPA
# Always build the HEAD SPA so the built_spa fixture's tombstone assertion
# passes. In Config B OMNIGENT_WEB_UI_DIST is then set to the old bundle,
# so the server serves that instead — but the HEAD build must exist for the
# fixture's structural check.
shell: bash
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: "Build pinned old server (Config A)"
if: ${{ inputs.server_version != '' }}
shell: bash
env:
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
run: |
tag="$SERVER_VERSION_INPUT"
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
echo "Invalid server_version: '$tag'" >&2; exit 1
fi
src="$RUNNER_TEMP/ui-server-src"
venv="$RUNNER_TEMP/ui-server-env"
git worktree add --detach "$src" "$tag"
uv venv --python 3.12 "$venv"
uv pip install --python "$venv/bin/python" \
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
"$venv/bin/omnigent" --version
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
- name: "Build old SPA from release tag (Config B: old SPA / new server)"
# Check out the old tag's web/ source into a temp dir, build it there,
# then set OMNIGENT_WEB_UI_DIST so the HEAD server serves that bundle.
if: ${{ inputs.ui_version != '' }}
shell: bash
env:
UI_VERSION_INPUT: ${{ inputs.ui_version }}
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
tag="$UI_VERSION_INPUT"
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
echo "Invalid ui_version: '$tag'" >&2; exit 1
fi
src="$RUNNER_TEMP/ui-spa-src"
git worktree add --detach "$src" "$tag"
# Build the old SPA in its own directory. pnpm install uses the
# old lock file; the build output lands in web/dist/ inside src.
cd "$src"
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
# vite.config.ts writes to ../omnigent/server/static/web-ui relative
# to web/ — resolve to the absolute path inside the old checkout.
built=$(cd web && node -e "const p=require('./vite.config.ts')" 2>/dev/null \
|| echo "$src/omnigent/server/static/web-ui")
# Fall back to checking both known locations.
if [ -d "$src/omnigent/server/static/web-ui" ]; then
built="$src/omnigent/server/static/web-ui"
elif [ -d "$src/web/dist" ]; then
built="$src/web/dist"
else
echo "Could not locate built SPA under $src" >&2; exit 1
fi
# Set OMNIGENT_WEB_UI_DIST so the HEAD server serves this old bundle.
# The HEAD SPA is still built above (built_spa fixture needs it for the
# tombstone assertion); the server uses OMNIGENT_WEB_UI_DIST to override
# which bundle it actually mounts.
echo "OMNIGENT_WEB_UI_DIST=$built" >> "$GITHUB_ENV"
- name: Run UI compat tests
shell: bash
env:
FULL_SUITE: ${{ inputs.full_suite }}
SHARD_ID: ${{ inputs.shard_id }}
NUM_SHARDS: ${{ inputs.num_shards }}
E2E_TMP_BASE: /tmp/omnigent-compat-ui-${{ github.run_id }}
run: |
mkdir -p "$E2E_TMP_BASE"
if [[ "$FULL_SUITE" == "true" ]]; then
uv run pytest tests/e2e_ui/ \
-m "not visual and not nightly" \
--ui-skip-build \
--splits="$NUM_SHARDS" \
--group="$((SHARD_ID + 1))" \
--tracing=retain-on-failure \
--screenshot=only-on-failure \
-v --tb=long --showlocals --log-level=INFO \
--timeout=180 \
--timeout-method=thread \
--basetemp="$E2E_TMP_BASE" \
--junitxml="$E2E_TMP_BASE/junit.xml" \
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
else
uv run pytest tests/e2e_ui/ \
-m compat_smoke \
--ui-skip-build \
-v --tb=long --showlocals --log-level=INFO \
--timeout=120 \
--timeout-method=thread \
--basetemp="$E2E_TMP_BASE" \
--junitxml="$E2E_TMP_BASE/junit.xml"
fi
- name: Upload logs on failure
if: ${{ failure() || cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: compat-ui-smoke-logs-${{ github.run_id }}${{ inputs.artifact_suffix }}
path: |
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/server.log
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/runner.log
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/.omnigent/logs/**/*.log
/tmp/omnigent-compat-ui-${{ github.run_id }}/junit.xml
retention-days: 7
if-no-files-found: warn
include-hidden-files: true
+2 -2
View File
@@ -81,9 +81,9 @@ runs:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and dev dependencies
- name: Install project and test dependencies
shell: bash
run: uv sync --locked --extra all --extra dev
run: uv sync --locked --extra all --group test
- name: Install binary dependencies
# npm install against .github/ci-deps/package.json with --ignore-scripts
+2 -2
View File
@@ -71,9 +71,9 @@ runs:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and dev dependencies
- name: Install project and test dependencies
shell: bash
run: uv sync --locked --extra all --extra dev
run: uv sync --locked --extra all --group test
- name: Install binary dependencies
# Mirrors e2e.yml. --ignore-scripts blocks npm postinstall hooks; we run
@@ -58,7 +58,8 @@ runs:
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.workdir }}
run: uv sync --extra all --extra dev
# Current callers are tools-less prose/JSON agents; repository checks never run.
run: uv sync --extra all
- name: Install Claude Code CLI
shell: bash
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Emit the UI backwards-compat matrix on $GITHUB_OUTPUT as `ui_matrix`.
#
# The UI matrix is server-only: for each final (non-prerelease) release tag
# at or above the backcompat floor, emit one cell per shard where the server
# is that release and the SPA + runner are both main. The runner axis is
# omitted because the SPA is always served by the server binary in production,
# so "new SPA vs old runner" is not a meaningful compat scenario for the UI.
#
# Env in:
# VERSIONS optional comma-separated override (e.g. "main,v0.9.0").
# When set, only release tokens (non-"main") become cells.
# NUM_SHARDS e2e_ui shard count per cell (default 3, mirrors e2e-ui.yml).
# Out (GITHUB_OUTPUT):
# ui_matrix={"include":[{"server":..,"shard_id":..,"num_shards":..}, ...]}
set -euo pipefail
_valid_version() {
[ "$1" = "main" ] || [[ "$1" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]
}
MIN_VERSION="${BACKCOMPAT_MIN_VERSION:-0.9.0}"
MIN_VERSION="${MIN_VERSION#v}"
_below_floor() {
[ "$1" = "main" ] && return 1
local v="${1#v}"
[ "$v" = "$MIN_VERSION" ] && return 1
[ "$(printf '%s\n%s\n' "$v" "$MIN_VERSION" | sort -V | head -1)" = "$v" ]
}
raw=()
if [ -n "${VERSIONS:-}" ]; then
IFS=',' read -ra raw <<<"$VERSIONS"
else
raw=()
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]')
fi
# Collect only release tokens (skip "main" — "main vs main" is the normal gate).
V=()
for v in "${raw[@]}"; do
v="${v#"${v%%[![:space:]]*}"}"
v="${v%"${v##*[![:space:]]}"}"
[ -z "$v" ] && continue
[ "$v" = "main" ] && continue
if ! _valid_version "$v"; then
echo "skipping invalid version token: '$v'" >&2; continue
fi
if _below_floor "$v"; then
echo "skipping '$v': below UI backcompat floor $MIN_VERSION" >&2; continue
fi
V+=("$v")
done
num_shards="${NUM_SHARDS:-3}"
# Each release tag produces 2 × num_shards jobs: one Config A cell
# (server=tag) and one Config B cell (ui=tag). Cap at 256 total.
max_ui=256
while [ "${#V[@]}" -gt 0 ] && [ "$(( ${#V[@]} * 2 * num_shards ))" -gt "$max_ui" ]; do
dropped="${V[${#V[@]} - 1]}"
unset 'V[${#V[@]}-1]'
V=("${V[@]}")
echo "ui-matrix cap: dropped oldest version '$dropped' to keep UI jobs <= $max_ui" >&2
done
items=()
for v in "${V[@]}"; do
# Config A: new SPA (HEAD), old server
for ((i = 0; i < num_shards; i++)); do
items+=("{\"server\":\"$v\",\"ui\":\"\",\"config\":\"A\",\"shard_id\":$i,\"num_shards\":$num_shards}")
done
# Config B: old SPA (tag), new server (HEAD)
for ((i = 0; i < num_shards; i++)); do
items+=("{\"server\":\"\",\"ui\":\"$v\",\"config\":\"B\",\"shard_id\":$i,\"num_shards\":$num_shards}")
done
done
json=$(IFS=,; echo "${items[*]:-}")
echo "ui_matrix={\"include\":[$json]}" >>"${GITHUB_OUTPUT:-/dev/stdout}"
echo "versions: ${V[*]:-(none)}; UI jobs: ${#items[@]} (${#V[@]} tags × 2 configs × $num_shards shards)" >&2
+62 -5
View File
@@ -21,6 +21,7 @@ REVIEW_LABEL = "waiting-for-review"
WAITING_DAYS = 7
CANONICAL_REPO = "omnigent-ai/omnigent"
MAX_CLOSURES_PER_RUN = 30
REVIEW_EVENTS = {"pull_request_review", "pull_request_review_comment"}
def label_names(item: dict[str, Any]) -> list[str]:
@@ -114,6 +115,16 @@ class GitHubAPI:
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
return pull
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
review, _ = self.request(
"GET", f"/repos/{self.repo}/pulls/{pull_number}/reviews/{review_id}"
)
return review
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
comment, _ = self.request("GET", f"/repos/{self.repo}/pulls/comments/{comment_id}")
return comment
def remove_label(self, issue_number: int, label: str) -> bool:
quoted = urllib.parse.quote(label, safe="")
try:
@@ -355,10 +366,14 @@ def apply_waiting_on_maintainer_activity(
pull_number = payload["pull_request"]["number"]
review = payload.get("review") or {}
actor = (review.get("user") or {}).get("login")
review_state = (review.get("state") or "").lower()
# An approval asks nothing of the author; it means the PR is ready.
if (review.get("state") or "").lower() == "approved":
if review_state == "approved":
print(f"#{pull_number}: approving review, leaving the label alone.")
return False
if review_state not in {"commented", "changes_requested"}:
print(f"#{pull_number}: {review_state or 'unknown'} review, leaving the label alone.")
return False
if is_slash_command(review.get("body")):
return False
reason = "a maintainer reviewed"
@@ -473,6 +488,42 @@ def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
return closed
def relay_integer(record: dict[str, Any], field: str) -> int:
value = record.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"Relay field {field!r} must be a positive integer")
return value
def hydrate_relay_event(
record: dict[str, Any], api: GitHubAPI, repo: str, expected_event: str
) -> tuple[str, dict[str, Any]]:
event_name = record.get("event_name")
if event_name not in REVIEW_EVENTS:
raise ValueError(f"Unsupported relayed event: {event_name!r}")
if event_name != expected_event:
raise ValueError(
f"Relayed event {event_name!r} does not match workflow event {expected_event!r}"
)
pull_number = relay_integer(record, "pull_number")
activity_id = relay_integer(record, "activity_id")
pull = api.get_pull(pull_number)
base_repo = ((pull.get("base") or {}).get("repo") or {}).get("full_name") or ""
if base_repo.lower() != repo.lower():
raise ValueError(f"Relayed PR #{pull_number} targets {base_repo!r}, not {repo!r}")
if event_name == "pull_request_review":
review = api.get_review(pull_number, activity_id)
return event_name, {"pull_request": pull, "review": review}
comment = api.get_review_comment(activity_id)
expected_url = f"https://api.github.com/repos/{repo}/pulls/{pull_number}"
if comment.get("pull_request_url") != expected_url:
raise ValueError(f"Review comment {activity_id} does not belong to PR #{pull_number}")
return event_name, {"pull_request": pull, "comment": comment}
def run(
event_name: str,
payload: dict[str, Any],
@@ -495,8 +546,7 @@ def run(
apply_waiting_on_maintainer_activity(event_name, payload, api)
def load_event_payload() -> dict[str, Any]:
path = os.environ.get("GITHUB_EVENT_PATH")
def load_json(path: str | None) -> dict[str, Any]:
if not path:
return {}
with open(path, encoding="utf-8") as handle:
@@ -509,8 +559,15 @@ def main() -> int:
if not token:
print("GITHUB_TOKEN is required", file=sys.stderr)
return 1
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run(event_name, load_event_payload(), GitHubAPI(token, repo), repo)
api = GitHubAPI(token, repo)
relay_path = os.environ.get("WAITING_ON_AUTHOR_RELAY_PATH")
if relay_path:
expected_event = os.environ.get("WAITING_ON_AUTHOR_RELAY_EVENT", "")
event_name, payload = hydrate_relay_event(load_json(relay_path), api, repo, expected_event)
else:
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
payload = load_json(os.environ.get("GITHUB_EVENT_PATH"))
run(event_name, payload, api, repo)
return 0
+118
View File
@@ -63,6 +63,8 @@ class FakeAPI:
issue_comments: dict[int, list[dict[str, Any]]] | None = None,
review_comments: dict[int, list[dict[str, Any]]] | None = None,
reviews: dict[int, list[dict[str, Any]]] | None = None,
review_by_id: dict[tuple[int, int], dict[str, Any]] | None = None,
review_comment_by_id: dict[int, dict[str, Any]] | None = None,
commits: dict[int, list[dict[str, Any]]] | None = None,
writers: list[str] | None = None,
):
@@ -73,6 +75,8 @@ class FakeAPI:
self.issue_comments = issue_comments or {}
self.review_comments = review_comments or {}
self.reviews = reviews or {}
self.review_by_id = review_by_id or {}
self.review_comment_by_id = review_comment_by_id or {}
self.commits = commits or {}
self.removed: list[tuple[int, str]] = []
self.closed: list[int] = []
@@ -83,6 +87,12 @@ class FakeAPI:
def get_pull(self, pull_number: int) -> dict[str, Any]:
return self.pull | {"number": pull_number}
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
return self.review_by_id[(pull_number, review_id)]
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
return self.review_comment_by_id[comment_id]
def remove_label(self, issue_number: int, label: str) -> bool:
self.removed.append((issue_number, label))
return True
@@ -442,6 +452,21 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
)
self.assertEqual(api.added, [])
def test_dismissed_review_leaves_the_label_alone(self) -> None:
api = self.dispatch(
"pull_request_review",
{
"pull_request": {"number": 12},
"review": {
"user": {"login": "maintainer1"},
"state": "dismissed",
"body": "stale feedback",
},
},
pull=pr(labels=[]),
)
self.assertEqual(api.added, [])
def test_commenting_review_applies_the_label(self) -> None:
api = self.dispatch(
"pull_request_review",
@@ -483,6 +508,99 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
)
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
def test_relayed_review_rehydrates_trusted_api_data(self) -> None:
pull = pr(labels=[]) | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
api = FakeAPI(
pull=pull,
review_by_id={
(12, 41): {
"id": 41,
"user": {"login": "maintainer1"},
"state": "changes_requested",
"body": "please fix",
}
},
)
event, payload = waiting_on_author.hydrate_relay_event(
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review",
)
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
def test_relayed_review_comment_rehydrates_author_reply(self) -> None:
pull = pr(author="alice") | {
"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}
}
api = FakeAPI(
pull=pull,
review_comment_by_id={
73: {
"id": 73,
"user": {"login": "alice"},
"body": "fixed",
"pull_request_url": (
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/12"
),
}
},
)
event, payload = waiting_on_author.hydrate_relay_event(
{
"event_name": "pull_request_review_comment",
"pull_number": 12,
"activity_id": 73,
},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_relayed_review_comment_must_match_pull(self) -> None:
pull = pr() | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
api = FakeAPI(
pull=pull,
review_comment_by_id={
73: {
"id": 73,
"pull_request_url": (
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/99"
),
}
},
)
with self.assertRaisesRegex(ValueError, "does not belong"):
waiting_on_author.hydrate_relay_event(
{
"event_name": "pull_request_review_comment",
"pull_number": 12,
"activity_id": 73,
},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
def test_relayed_event_must_match_workflow_event(self) -> None:
api = FakeAPI()
with self.assertRaisesRegex(ValueError, "does not match workflow event"):
waiting_on_author.hydrate_relay_event(
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
def test_applying_clears_waiting_for_review(self) -> None:
api = self.dispatch(
"issue_comment",
+118
View File
@@ -0,0 +1,118 @@
name: Benchmark (host sessions)
# Profiles the host-bound session lifecycle — create → host.launch_runner →
# runner boot → first token (`session_cold_start`), plus restart and the
# common session actions around it — and renders the journey × metric matrix
# into the job summary. Informational: no thresholds, so a noisy shared
# runner can't block a PR; regression *gating* stays with benchmark-pr.yml
# (store paths) and release.yml (release cuts). The seeded, backend-matrix
# trend numbers stay with the nightly benchmark.yml; this workflow's value is
# a fresh matrix on the PRs that actually move these numbers.
#
# Uses dev/benchmarks/omnigent (real server + real `omni host` daemon +
# runner against a zero-latency mock LLM — no agent CLI or credentials).
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "omnigent/host/**"
- "omnigent/runner/**"
- "omnigent/server/**"
- "dev/benchmarks/**"
- ".github/workflows/benchmark-host.yml"
workflow_dispatch:
inputs:
journeys:
description: "Comma-separated journeys (blank = the host-session set)"
required: false
default: ""
iterations:
description: "Requests per run (runner journeys stay capped at 5)"
required: false
default: "100"
runs:
description: "Timed runs per journey"
required: false
default: "3"
permissions:
contents: read
env:
# No web SPA build during `uv sync` (setup.py _build_web_ui): nothing here
# serves the bundle, and the build otherwise times out on public npm.
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
# The host-session set: the host-bound lifecycle journeys first, then the
# common HTTP session actions a user drives around them. Matches the
# journey names in dev/benchmarks/omnigent/journeys.py (ALL_JOURNEYS).
DEFAULT_JOURNEYS: >-
session_cold_start,session_cold_restart,warm_turn,time_to_first_token,interrupt,create_session,fork_session,list_sessions,get_session,load_conversation_history
JOURNEYS: ${{ (github.event_name == 'workflow_dispatch' && inputs.journeys) || '' }}
ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.iterations || '100' }}
RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || '3' }}
concurrency:
# PR pushes cancel the previous run; manual dispatches never cancel each
# other (unique run_id).
group: benchmark-host-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
benchmark-host:
name: Host session benchmark (sqlite)
if: github.repository == 'omnigent-ai/omnigent' && !github.event.pull_request.draft
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
- name: Install dependencies
# Same runtime install as the other benchmark workflows so numbers stay
# comparable across them.
run: uv sync --extra databricks
- name: Run host-session benchmark
# Throwaway empty SQLite DB (run.py default): these journeys measure
# process spin-up and turn latency, not query scale — corpus-scale
# numbers live in the nightly benchmark.yml.
run: |
uv run --no-sync dev/benchmarks/omnigent/run.py \
--journeys "${JOURNEYS:-$DEFAULT_JOURNEYS}" \
--iterations "$ITERATIONS" \
--runs "$RUNS" \
--output benchmark-results-host.json
- name: Render results matrix to job summary
if: always()
run: |
if [[ -f benchmark-results-host.json ]]; then
uv run --no-sync dev/benchmarks/omnigent/report_markdown.py \
--title "Host session benchmark" \
benchmark-results-host.json >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload benchmark results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: benchmark-results-host-${{ github.run_id }}
path: benchmark-results-host.json
retention-days: 30
if-no-files-found: warn
+4 -1
View File
@@ -55,7 +55,10 @@ jobs:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra databricks
# pexpect drives omnigent polly via PTY for the cli_startup journey.
run: |
uv sync --extra databricks
uv pip install pexpect
# Use the same corpus size as the nightly so baseline numbers are
# directly comparable. Cache the seeded DB on the schema head + seed
+16 -1
View File
@@ -127,7 +127,7 @@ jobs:
- name: Install dependencies
# `databricks` extra carries psycopg[binary] for the Postgres backend.
run: uv sync --extra dev --extra databricks
run: uv sync --extra databricks
- name: Install MySQL driver
# mysqlclient (mysql+mysqldb://) needs the system client library and is
@@ -180,6 +180,10 @@ jobs:
--database-uri "${{ steps.db.outputs.uri }}" \
--sessions "$SESSIONS" --items-per-session "$ITEMS"
# pexpect drives omnigent polly via PTY for the cli_startup journey.
- name: Install CLI startup dependencies
run: uv pip install pexpect
- name: Run benchmark
run: |
uv run --no-sync dev/benchmarks/omnigent/run.py \
@@ -189,6 +193,17 @@ jobs:
--network-delay-ms "$NETWORK_DELAY_MS" \
--output "benchmark-results-${{ matrix.backend }}.json"
- name: Render results matrix to job summary
# The JSON artifact feeds the trend dashboard; this makes the same
# numbers readable on the run page without downloading it.
if: always()
run: |
if [[ -f "benchmark-results-${{ matrix.backend }}.json" ]]; then
uv run --no-sync dev/benchmarks/omnigent/report_markdown.py \
--title "Benchmark results" \
"benchmark-results-${{ matrix.backend }}.json" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload benchmark results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
+4 -4
View File
@@ -188,7 +188,7 @@ jobs:
- name: Install dependencies
# matrix.extra (e.g. "databricks") adds an extra for lanes that need it;
# empty for the default lanes.
run: uv sync --locked --extra all --extra dev ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
run: uv sync --locked --extra all --group test ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
- name: Run pytest
shell: bash
@@ -266,7 +266,7 @@ jobs:
with:
enable-cache: true
- name: Install dependencies
run: uv sync --locked --extra all --extra dev --extra databricks
run: uv sync --locked --extra all --group test --extra databricks
- name: Run store + DB tests against PostgreSQL
env:
OMNIGENT_TEST_DB_URI: postgresql+psycopg://postgres:omnigent@localhost:5432/omnigent_root
@@ -314,7 +314,7 @@ jobs:
- name: Install system MySQL client library
run: sudo apt-get update -qq && sudo apt-get install -y -q libmysqlclient-dev
- name: Install dependencies
run: uv sync --locked --extra all --extra dev --extra databricks && uv pip install mysqlclient
run: uv sync --locked --extra all --group test --extra databricks && uv pip install mysqlclient
- name: Run store + DB tests against MySQL
env:
OMNIGENT_TEST_DB_URI: mysql+mysqldb://root:omnigent@127.0.0.1:3306/omnigent_root
@@ -389,7 +389,7 @@ jobs:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --locked --extra all --extra dev
run: uv sync --locked --extra all --group test
- name: Build parity sidecar
if: steps.sidecar-cache.outputs.cache-hit != 'true'
+2 -1
View File
@@ -247,7 +247,8 @@ jobs:
- name: Install dependencies
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
run: uv sync --extra all --extra dev
# Agents classify or edit external-site prose; repository checks never run.
run: uv sync --extra all
- name: Install Claude Code CLI
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
+2 -2
View File
@@ -181,8 +181,8 @@ jobs:
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project + dev extras
run: uv sync --locked --extra all --extra dev
- name: Install project + test dependencies
run: uv sync --locked --extra all --group test
- name: Install bubblewrap + tmux
# bubblewrap: the UI tests open terminals under os_env, whose
-119
View File
@@ -1,119 +0,0 @@
name: Electron Build
# Manually-triggered build of the Electron desktop shell (web/electron) for
# Linux and Windows. Each platform packages on its own native runner —
# electron-builder does not reliably cross-compile installers — and uploads the
# installers PLUS the electron-updater feed manifests (latest-linux.yml /
# latest.yml) as downloadable workflow artifacts. Unsigned: no signing creds are
# wired here, so `CSC_IDENTITY_AUTO_DISCOVERY=false` forces an unsigned build
# rather than failing when a cert is absent. No publishing to a provider / no
# release upload (`--publish never`): the artifacts are captured here for manual
# placement onto the omnigent.ai update feed (omnigent-site repo + artifact host).
#
# Run it from the Actions tab (Run workflow). macOS is intentionally omitted —
# its signed/notarized build lives elsewhere.
on:
workflow_dispatch:
inputs:
ref:
description: "Branch, tag, or SHA to build."
required: false
default: ""
permissions:
contents: read
concurrency:
# One build per ref: back-to-back manual dispatches on the same ref queue
# instead of running concurrently (keyed on ref only — including run_id would
# make every run its own group, defeating the serialization).
group: electron-build-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
# Keep building the other platform even if one fails, so a Windows-only
# break still yields the Linux installers (and vice versa).
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
build-script: build:linux
- os: windows-latest
platform: win
build-script: build:win
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install dependencies
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |-
pnpm install --frozen-lockfile --filter @omnigent/electron
pnpm install --frozen-lockfile --filter web
- name: Build ${{ matrix.platform }} app
working-directory: web/electron
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
# No signing credentials in CI: force an unsigned build instead of
# letting electron-builder fail hunting for a certificate.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# electron-builder downloads Electron/tooling from GitHub; the token
# lifts the anonymous rate limit that otherwise flakes downloads.
GH_TOKEN: ${{ github.token }}
run: pnpm run ${{ matrix.build-script }} -- --publish never
# One artifact per platform bundling the COMPLETE electron-updater feed —
# the installer(s), the .blockmap electron-updater needs for differential
# downloads (referenced by path inside latest*.yml; the .deb has no
# blockmap since debs aren't differentially updated), and the feed
# manifest (latest-linux.yml / latest.yml). upload-artifact zips all
# matched files into a single download, so each platform yields one zip
# whose contents can be dropped straight onto a feed root (local HTTP
# server for testing, or public/_desktop/updates/ on the artifact host).
# Ship only the distributables + feed files, not electron-builder's
# unpacked intermediates (dist/*-unpacked).
#
# electron-builder writes the latest*.yml manifests to dist/ even under
# --publish never (a publish config exists in build.*.publish, so
# update-info generation runs; --publish only skips the provider upload).
# The manifest lists each artifact with sha512 + size + relative url.
- name: Upload Linux feed
if: matrix.platform == 'linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: omnigent-desktop-linux
path: |
web/electron/dist/*.AppImage
web/electron/dist/*.AppImage.blockmap
web/electron/dist/*.deb
web/electron/dist/latest-linux.yml
if-no-files-found: error
retention-days: 14
- name: Upload Windows feed
if: matrix.platform == 'win'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: omnigent-desktop-win
path: |
web/electron/dist/*.exe
web/electron/dist/*.exe.blockmap
web/electron/dist/latest.yml
if-no-files-found: error
retention-days: 14
+2 -2
View File
@@ -233,10 +233,10 @@ jobs:
# PAT passthrough for the codex / claude-sdk auth commands.
echo "DATABRICKS_BEARER=$LLM_API_KEY" >> "$GITHUB_ENV"
- name: Install project and dev dependencies
- name: Install project and test dependencies
# Matches e2e.yml; ``--extra all`` pulls the harness SDKs so the
# executor adapters import at collection time.
run: uv sync --extra all --extra dev
run: uv sync --extra all --group test
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
+2 -2
View File
@@ -229,8 +229,8 @@ jobs:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project + dev extras
run: uv sync --locked --extra all --extra dev
- name: Install project + test dependencies
run: uv sync --locked --extra all --group test
- name: Install bubblewrap + tmux
# bubblewrap: the UI tests open terminals under os_env, whose
+1 -1
View File
@@ -157,7 +157,7 @@ jobs:
- name: Install dependencies
# Matches ci.yml; ``--extra all`` pulls the harness SDKs so
# executor adapters import at collection time.
run: uv sync --extra all --extra dev
run: uv sync --extra all --group test
- name: Run pytest target
# Inputs validated by prep. Word-splitting on $TEST_TARGET /
+30 -27
View File
@@ -25,8 +25,10 @@ name: Issue Triage
# 5. Flags incomplete issues — `needs-info` (replaces priority label)
# 6. Optionally comments when a duplicate or related issue is found
# (disabled by default; never comments when nothing matches)
# 7. Optionally closes validated high-confidence duplicates (disabled by default)
# 8. Assigns P0/P1 issues to a maintainer via round-robin
# 7. Assigns an owner to every triaged open issue via round-robin — duplicates
# included, so the owner persists if the issue is later reopened
# 8. Optionally closes validated high-confidence duplicates (disabled by
# default), after the owner above is assigned
on:
issues:
@@ -233,7 +235,8 @@ jobs:
- name: Install dependencies
if: steps.creds.outputs.available == 'true'
run: uv sync --extra all --extra dev
# The tools-less triage agent emits JSON; no repository checks run.
run: uv sync --extra all
- name: Install Claude Code CLI
if: steps.creds.outputs.available == 'true'
@@ -713,40 +716,25 @@ jobs:
exit 0
fi
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
if [ "$duplicate_decision" = "duplicate" ]; then
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
if [ "$close_duplicate_issue" = "true" ]; then
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json state --jq '.state')
if [ "$issue_state" = "OPEN" ]; then
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
--duplicate-of "$duplicate_of"
fi
else
echo "Duplicate closure disabled; leaving issue open."
fi
exit 0
fi
# Assign an owner to every triaged open issue, BEFORE the duplicate
# closure below. Duplicates get an owner too, and it persists if the
# issue is later reopened — triage only fires on `opened`, so a
# reopened issue would otherwise come back unassigned.
# If the issue was filed by a maintainer, assign it to them directly.
author=$(jq -r '.author.login // empty' /tmp/issue.json)
maintainer_assigned=false
if [ -n "$author" ] && grep -qxF "$author" .github/MAINTAINER; then
echo "Issue filed by maintainer $author — assigning to author"
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json state --jq '.state')
if [ "$issue_state" = "OPEN" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
maintainer_assigned=true
fi
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
maintainer_assigned=true
fi
# Otherwise, assign an owner: the least-loaded area owner, with LLM
# rank as a tiebreaker (load primary, rank secondary). Symmetric with
# the PR reviewer path. Skipped if the maintainer-author was already
# assigned above. Every triaged issue gets an owner — the only issues
# assigned above. Every triaged issue gets an owner — duplicates
# included, even ones about to be closed below — so the only issues
# left unassigned are needs_info ones (too vague to route until the
# reporter adds detail).
needs_info=$(jq -r '.needs_info // false' /tmp/triage_result.json)
@@ -793,11 +781,26 @@ jobs:
assignee=$(cat /tmp/assignee.txt)
if [ -n "$assignee" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
fi
fi
# Finally, close the issue if it is a high-confidence duplicate and
# closure is enabled. The owner assigned above stays on the issue,
# ready if it is reopened.
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
if [ "$duplicate_decision" = "duplicate" ]; then
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
if [ "$close_duplicate_issue" = "true" ]; then
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json state --jq '.state')
if [ "$issue_state" = "OPEN" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
--duplicate-of "$duplicate_of"
fi
else
echo "Duplicate closure disabled; leaving issue open."
fi
fi
+55
View File
@@ -0,0 +1,55 @@
name: Kustomize validate
# Renders every deploy/kubernetes overlay with `kustomize build` so manifest
# drift (duplicate bases, missing patches, invalid YAML) is caught in CI
# rather than at deploy time. Only runs when overlay or base files change.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'deploy/kubernetes/**'
push:
branches:
- main
- 'release/v[0-9]*'
paths:
- 'deploy/kubernetes/**'
permissions:
contents: read
concurrency:
group: kustomize-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
validate:
name: Kustomize build (overlays)
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install kustomize
run: |
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
sudo mv kustomize /usr/local/bin/
- name: Render all overlays
run: |
failed=0
for overlay in deploy/kubernetes/overlays/*/; do
name="$(basename "$overlay")"
echo "::group::$name"
if kustomize build "$overlay"; then
echo "::endgroup::"
else
echo "::endgroup::"
echo "::error::kustomize build failed for overlay '$name'"
failed=1
fi
done
exit "$failed"
+9 -1
View File
@@ -75,7 +75,15 @@ jobs:
# `--locked` is the hard gate: fails if uv.lock is out of sync with
# pyproject.toml (a bare `uv run pre-commit` would re-lock first and mask
# a stale lockfile). Fix locally with `uv lock`.
run: uv sync --locked --extra dev
# Pyrefly checks optional integrations against their real packages.
# Compose capability extras with lint tooling instead of duplicating
# runtime dependencies in the repository-only lint group.
run: |
uv sync --locked --group lint \
--extra hindsight \
--extra nimble \
--extra s3 \
--extra tracing
# Sets up Node 20 + pnpm, with pnpm dependency caching keyed on the
# workspace lockfile. pnpm is pinned in .github/actions/setup-pnpm.
+2 -1
View File
@@ -164,7 +164,8 @@ jobs:
- name: Install dependencies
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
run: uv sync --extra all --extra dev
# Reviews a prefetched diff against trusted main; it does not run PR checks.
run: uv sync --extra all
- name: Install Claude Code CLI
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
+5 -5
View File
@@ -283,7 +283,7 @@ jobs:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
run: uv sync
- name: Find previous stable release tag
id: prev
@@ -308,7 +308,7 @@ jobs:
if: steps.prev.outputs.found == 'true'
run: |
git checkout "${{ steps.prev.outputs.tag }}"
uv sync --extra dev
uv sync
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "sqlite:///bench.db" \
--sessions 5000 --items-per-session 200
@@ -362,7 +362,7 @@ jobs:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
run: uv sync
- name: Run baseline benchmark
run: |
@@ -413,7 +413,7 @@ jobs:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
run: uv sync
# The seeded bench.db is at the previous release's schema head. The
# candidate (newer code) auto-migrates on server boot, but the z7
@@ -473,7 +473,7 @@ jobs:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
run: uv sync
- name: Download results
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+2 -1
View File
@@ -188,7 +188,8 @@ jobs:
- name: Install dependencies
if: steps.creds.outputs.available == 'true'
run: uv sync --extra all --extra dev
# The tools-less triage agent emits JSON; no repository checks run.
run: uv sync --extra all
- name: Install Claude Code CLI
if: steps.creds.outputs.available == 'true'
+174 -3
View File
@@ -30,6 +30,23 @@ on:
schedule:
# Every 12 hours (00:00 and 12:00 UTC).
- cron: "0 */12 * * *"
# Fast smoke on PRs that touch the server↔runner contract surface.
# Runs both Config 1 (new server, old runner) and Config 2 (new runner,
# old server) against the latest stable release so protocol regressions
# surface before merge, not in the overnight full matrix.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "omnigent/runner/**"
- "omnigent/server/**"
- "omnigent/host/**"
- "web/src/**"
- "tests/e2e/**"
- "tests/e2e_ui/**"
- "tests/_helpers/compat.py"
- ".github/actions/compat-smoke-run/**"
- ".github/actions/compat-smoke-ui-run/**"
- ".github/workflows/server-compat.yml"
concurrency:
group: backcompat-${{ github.workflow }}-${{ github.sha }}
@@ -39,11 +56,118 @@ permissions:
contents: read
jobs:
# Compute the full pairwise (server, runner) matrices. Integration is the
# single openai-agents leg (claude-sdk/codex reject the mock LLM's
# "mock-model"); e2e is sharded per cell. See backcompat-pairwise-matrix.sh.
# ── Fast smoke: both compat configs against the latest stable release ──────
# Runs on every PR that touches the server↔runner contract surface (paths
# filter above), plus every schedule/dispatch run. Resolves the latest
# final (non-prerelease) tag once, then fans out to Config 1 and Config 2.
# The full pairwise matrix (backcompat-e2e / backcompat-integration) still
# runs on schedule/dispatch and covers the broader version history.
resolve-latest:
name: Resolve latest stable tag
# PRs: always. Schedule/dispatch: always.
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
latest_tag: ${{ steps.tag.outputs.latest_tag }}
steps:
- name: Checkout (tags only)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
sparse-checkout: .github/scripts/ci
persist-credentials: false
- name: Resolve latest final tag
id: tag
shell: bash
run: |
# Latest final (non-prerelease) tag by version sort.
tag=$(git tag --sort=-v:refname \
| grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]' \
| head -1)
if [ -z "$tag" ]; then
echo "No stable release tag found" >&2; exit 1
fi
echo "latest_tag=$tag" >> "$GITHUB_OUTPUT"
echo "Resolved latest stable tag: $tag" >&2
compat-smoke-config1:
name: "Compat smoke Config 1 (new server / old runner ${{ needs.resolve-latest.outputs.latest_tag }})"
needs: resolve-latest
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
fetch-depth: 0
- name: Run compat smoke (Config 1)
uses: ./.github/actions/compat-smoke-run
with:
runner_version: ${{ needs.resolve-latest.outputs.latest_tag }}
artifact_suffix: "-config1"
compat-smoke-config2:
name: "Compat smoke Config 2 (new runner / old server ${{ needs.resolve-latest.outputs.latest_tag }})"
needs: resolve-latest
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
fetch-depth: 0
- name: Run compat smoke (Config 2)
uses: ./.github/actions/compat-smoke-run
with:
server_version: ${{ needs.resolve-latest.outputs.latest_tag }}
artifact_suffix: "-config2"
compat-smoke-ui-config-a:
name: "Compat smoke UI Config A (new SPA / old server ${{ needs.resolve-latest.outputs.latest_tag }})"
needs: resolve-latest
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
fetch-depth: 0
- name: Run UI compat smoke (Config A)
uses: ./.github/actions/compat-smoke-ui-run
with:
server_version: ${{ needs.resolve-latest.outputs.latest_tag }}
artifact_suffix: "-ui-config-a"
compat-smoke-ui-config-b:
name: "Compat smoke UI Config B (old SPA ${{ needs.resolve-latest.outputs.latest_tag }} / new server)"
needs: resolve-latest
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
fetch-depth: 0
- name: Run UI compat smoke (Config B)
uses: ./.github/actions/compat-smoke-ui-run
with:
ui_version: ${{ needs.resolve-latest.outputs.latest_tag }}
artifact_suffix: "-ui-config-b"
# ── Full pairwise matrix (schedule + dispatch only) ────────────────────────
# Compute the full pairwise (server, runner) and UI matrices.
# Integration is the single openai-agents leg (claude-sdk/codex reject the
# mock LLM's "mock-model"); e2e is sharded per cell.
# UI matrix is server-only (runner is always main; the SPA is always HEAD).
setup:
name: setup
# The full pairwise matrix is expensive — skip it on PR triggers (the
# fast smoke jobs above cover the PR case).
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
@@ -132,3 +256,50 @@ jobs:
harness: ${{ matrix.harness }}
model: ${{ matrix.model }}
workers: ${{ matrix.workers }}
# tests/e2e_ui for every old server tag × shard (server axis only).
setup-ui:
name: setup-ui
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
ui_matrix: ${{ steps.matrix.outputs.ui_matrix }}
steps:
- name: Check out CI scripts + tags
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
sparse-checkout: .github/scripts/ci
fetch-depth: 0
persist-credentials: false
- name: Compute UI matrix
id: matrix
env:
VERSIONS: ${{ github.event.inputs.versions }}
NUM_SHARDS: "3"
run: bash .github/scripts/ci/backcompat-ui-matrix.sh
backcompat-e2e-ui:
name: "Backcompat e2e-ui (Config ${{ matrix.config }}: ${{ matrix.config == 'A' && matrix.server || matrix.ui }}, shard ${{ matrix.shard_id }}/${{ matrix.num_shards }})"
needs: setup-ui
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 6
matrix: ${{ fromJSON(needs.setup-ui.outputs.ui_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Run e2e-ui suite for this cell
uses: ./.github/actions/compat-smoke-ui-run
with:
server_version: ${{ matrix.server }}
ui_version: ${{ matrix.ui }}
full_suite: "true"
shard_id: ${{ matrix.shard_id }}
num_shards: ${{ matrix.num_shards }}
artifact_suffix: "-config${{ matrix.config }}-${{ matrix.config == 'A' && matrix.server || matrix.ui }}-shard${{ matrix.shard_id }}"
+2 -2
View File
@@ -106,8 +106,8 @@ jobs:
# the container's system Python, not the host interpreter).
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project + dev extras
run: uv sync --extra all --extra dev
- name: Install project + test dependencies
run: uv sync --extra all --group test
# No "playwright install": the pinned image ships matching Chromium + deps
# under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright).
+2 -2
View File
@@ -157,8 +157,8 @@ jobs:
# must not share a key or a cross-restore would mismatch.
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project + dev extras
run: uv sync --extra all --extra dev
- name: Install project + test dependencies
run: uv sync --extra all --group test
# No "playwright install": the pinned image already ships matching Chromium
# + system deps under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright), so the
@@ -0,0 +1,78 @@
name: Waiting on Author Review Run
# Privileged half of the fork-review relay. The artifact contains numeric IDs
# only; the script re-fetches the PR and review/comment from GitHub before using
# actor identity, review state, or comment content. No PR code is checked out.
on:
workflow_run:
workflows: [Waiting on Author Review]
types: [completed]
permissions:
contents: read
concurrency:
group: waiting-on-author-review-run-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
hygiene:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Download recorded review event IDs
id: download
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const { owner, repo } = context.repo;
const arts = await github.rest.actions.listWorkflowRunArtifacts({
owner, repo, run_id: context.payload.workflow_run.id,
});
const art = arts.data.artifacts.find(
a => a.name === 'waiting-on-author-review-event'
);
if (!art) {
core.info('No fork-review artifact; the direct review job handled this event.');
core.setOutput('found', 'false');
return;
}
const dl = await github.rest.actions.downloadArtifact({
owner, repo, artifact_id: art.id, archive_format: 'zip',
});
fs.writeFileSync(
`${process.env.RUNNER_TEMP}/waiting-on-author-review.zip`,
Buffer.from(dl.data)
);
core.setOutput('found', 'true');
- name: Check out trusted .github
if: steps.download.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Apply relayed waiting-on-author state
if: steps.download.outputs.found == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
RELAY_ZIP: ${{ runner.temp }}/waiting-on-author-review.zip
RELAY_DIR: ${{ runner.temp }}/waiting-on-author-review
WAITING_ON_AUTHOR_RELAY_EVENT: ${{ github.event.workflow_run.event }}
WAITING_ON_AUTHOR_RELAY_PATH: ${{ runner.temp }}/waiting-on-author-review/event.json
run: |
mkdir -p "$RELAY_DIR"
unzip -q "$RELAY_ZIP" -d "$RELAY_DIR"
python3 .github/scripts/waiting_on_author.py
@@ -0,0 +1,71 @@
name: Waiting on Author Review
# Review events on fork PRs receive a read-only token. Same-repo reviews run the
# hygiene script directly; fork reviews record only GitHub-provided numeric IDs
# for the privileged workflow_run consumer. No PR code is checked out.
on:
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: read
concurrency:
group: waiting-on-author-review-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
hygiene:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check out .github
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Update waiting-on-author state
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 .github/scripts/waiting_on_author.py
record:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Record review event IDs
env:
EVENT_NAME: ${{ github.event_name }}
PULL_NUMBER: ${{ github.event.pull_request.number }}
ACTIVITY_ID: ${{ github.event.review.id || github.event.comment.id }}
run: |
mkdir -p relay
jq -n \
--arg event_name "$EVENT_NAME" \
--argjson pull_number "$PULL_NUMBER" \
--argjson activity_id "$ACTIVITY_ID" \
'{event_name: $event_name, pull_number: $pull_number, activity_id: $activity_id}' \
> relay/event.json
- name: Upload review event IDs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: waiting-on-author-review-event
path: relay/event.json
retention-days: 1
if-no-files-found: error
@@ -9,6 +9,8 @@ on:
- .github/scripts/waiting_on_author.py
- .github/scripts/waiting_on_author_test.py
- .github/workflows/waiting-on-author.yml
- .github/workflows/waiting-on-author-review.yml
- .github/workflows/waiting-on-author-review-run.yml
- .github/workflows/waiting-on-author-test.yml
workflow_dispatch:
+2 -4
View File
@@ -6,16 +6,14 @@ name: Waiting on Author Hygiene
# once a review is submitted). The two labels are mutually exclusive. PRs left
# waiting on the author for 7 days are closed. The workflow runs from trusted
# default-branch code and never checks out PR-authored files.
# Review events use the read-only -> workflow_run relay in
# waiting-on-author-review.yml and waiting-on-author-review-run.yml.
on:
pull_request_target:
types: [synchronize, labeled]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
+2 -1
View File
@@ -50,7 +50,8 @@ jobs:
enable-cache: true
- name: Install dependencies
run: uv sync --locked --extra dev
# tests/inner includes tracing tests that import the OpenTelemetry SDK.
run: uv sync --locked --group test --extra tracing
- name: Import + CLI smoke
run: |
+1 -1
View File
@@ -142,7 +142,7 @@ repos:
# Fail if routing.proto changed without regenerating the committed
# bindings (or vice versa). Verify-only, not a fixer: regen needs
# grpcio-tools, so CI's `uv sync --extra dev` enforces it (like ktlint).
# grpcio-tools, so CI's `uv sync --group lint` enforces it (like ktlint).
- id: routing-pb2-fresh
name: routing protobuf bindings are up to date
language: system
+177
View File
@@ -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 / Test/CI] Honor OmnigentClient timeout for ordinary Python SDK HTTP requests. (#4509)
- [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)
+10 -6
View File
@@ -85,19 +85,23 @@ cd omnigent
uv python install
uv venv --python "$(cat .python-version)"
uv sync --extra all --extra dev
uv sync --extra all --group dev
source .venv/bin/activate # or prefix commands with `uv run`
```
Repository-only dependencies use PEP 735 groups: `lint` for static checks and
code generation, `test` for pytest, and `dev` for both. Product capabilities
remain installable extras. Plain `uv sync` installs neither group by default.
Common checks:
Pyrefly is the canonical Python type checker for the repository.
```bash
uv run pytest # Python tests (e2e/live skipped by default)
uv run ruff check . && uv run ruff format --check .
uv run --no-sync pyrefly check # Python type checking (core and client SDK)
uv run pre-commit run --all-files
uv run --no-sync pytest # Python tests (e2e/live skipped by default)
uv run --no-sync ruff check . && uv run --no-sync ruff format --check .
uv run --no-sync pyrefly check # Python type checking (core and client SDK)
uv run --no-sync pre-commit run --all-files
```
When touching `web/`:
@@ -135,7 +139,7 @@ test. A fresh worktree needs its own Python environment first:
```bash
cd /path/to/omnigent-worktree
uv sync --extra all --extra dev
uv sync --extra all --group dev
omnidev
```
+7 -2
View File
@@ -365,8 +365,10 @@ Face Spaces**, **Modal**, **Cloudflare** (serverless, scale-to-zero), and
covered too — and a **Cloudflare quick tunnel** (public) or **Tailscale**
(private) reaches a server running on your own laptop without a deploy. The
server can also provision a cloud sandbox per session (*managed hosts*), so no
laptop has to stay online. The full menu of targets, the database options, and
the sandbox setup live in
laptop has to stay online. The full menu of targets, the database options, the
sandbox setup, and
[branding/white-labeling](https://github.com/omnigent-ai/omnigent/blob/main/deploy/README.md#branding-white-labeling)
live in
[`deploy/README.md`](https://github.com/omnigent-ai/omnigent/blob/main/deploy/README.md).
Once the server is up, sign in and register your laptop as a host:
@@ -411,6 +413,9 @@ and they're in. Signup is invite-only.
- **Share a live session.** Hit **Share** in the web UI and send the link;
teammates watch your agent work and chat with it in real time.
- **Leave a shared session.** Done with a session someone shared with you?
Pick **Leave session** from its sidebar row menu to drop it from your
sidebar. Nothing is deleted — the owner keeps it and can share it again.
- **Co-drive.** A teammate co-attaches to your running session; their
messages execute on **your** machine. Great for pairing or handing the
keyboard to a domain expert mid-investigation.
+34
View File
@@ -498,6 +498,40 @@ to set and sanitize the identity header, and read
[`docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy`](docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy)
first.
## Branding (white-labeling)
Customize the app name, landing heading, and logos with a `branding:` block in
the server config (`omnigent server -c config.yaml`, or `<data_dir>/config.yaml`
`/data/config.yaml` in the Docker stack). Takes effect on the next server
start.
```yaml
branding:
app_name: "Acme Agent" # tab title, sidebar wordmark, login screen
heading: "How can I help?" # landing hero; "" hides it, omit to keep the default
logo: # a bare string sets `main`; or per-variant:
main: logo.png # branding-assets/logo.png
loading: loading.webp # working indicator (falls back to main)
favicon: favicon.png # browser-tab icon
powered_by: true # "Powered by Omnigent" credit; false to hide
```
Logo files must live under a dedicated `branding-assets/` directory beside the
config file (for example, `/data/branding-assets/logo.png`). PNG, JPEG, GIF,
WebP, and ICO files up to 5 MiB are accepted only after full decoder validation.
ICO files must contain only PNG-backed entries; every directory entry is bounded
and decoded independently, while DIB/BMP-backed entries are rejected. Malformed,
truncated, oversized, overlapping, trailing-payload, SVG, symlinked, escaped, and
non-image files are ignored. Images are also bounded to 4096 pixels per side,
128 frames, 16 megapixels per frame, and 64 megapixels across all decoded frames.
The values are served over the unauthenticated `GET /v1/info` and
`GET /v1/branding/logo/<variant>` endpoints so the login screen is branded before
sign-in. Any unset field keeps its built-in default, so a partial block is fine.
The small "Powered by Omnigent" credit under the landing composer appears only
once you set custom branding; `powered_by: false` hides it even then. It always
shows the Omnigent mascot, never your logo.
## Adding a new deploy target
Drop a new subdirectory under `deploy/<target>/` with a `README.md`
+11
View File
@@ -147,6 +147,17 @@ UC Volume wheel paths because `uv lock` validates path sources locally.
Re-running is safe — every step is idempotent.
Release features are off by default. Enable one or more for the whole app by
adding the comma-separated deploy argument, then reload the web app after the
redeploy:
```bash
--features usage_page,harness_install
```
See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for the current
inventory and rollback procedure.
> [!TIP]
> To lock against a private PyPI mirror or proxy instead of public
> PyPI, set `UV_INDEX_URL` before running `deploy.py`.
+5
View File
@@ -21,6 +21,9 @@ variables:
UC schema (catalog.schema) holding the OTel destination tables.
The platform writes to <schema>.otel_logs, otel_metrics, otel_spans.
default: main.omnigent_logs
features:
description: "Comma-separated deployment-wide release features."
default: ""
resources:
apps:
@@ -44,6 +47,8 @@ resources:
value_from: artifact_volume
- name: OTEL_TRACES_SAMPLER
value: 'always_on'
- name: OMNIGENT_FEATURES
value: "${var.features}"
resources:
- name: postgres
postgres:
+10
View File
@@ -567,6 +567,14 @@ def _parse_args() -> argparse.Namespace:
"<schema>.otel_{logs,metrics,spans}."
),
)
parser.add_argument(
"--features",
default="",
help=(
"Comma-separated deployment-wide release features, e.g. "
"'usage_page'. Empty keeps every release feature off."
),
)
parser.add_argument(
"--target",
default="prod",
@@ -746,6 +754,8 @@ def _bundle_vars(args: argparse.Namespace) -> list[str]:
f"volume_name={args.volume_name}",
"--var",
f"otel_table_schema={args.otel_table_schema}",
"--var",
f"features={args.features}",
]
+13
View File
@@ -15,6 +15,12 @@ POSTGRES_PASSWORD=change-me-please
# Host port the omnigent container is published on. Default 8000.
# OMNIGENT_PORT=8000
# ── Release features ─────────────────────────────────────
# Comma-separated deployment-wide release features. Empty/unset keeps every
# release feature off. Unknown names fail startup so typos cannot silently
# change rollout behavior. Current keys: usage_page, harness_install.
# OMNIGENT_FEATURES=usage_page
# ── Image ────────────────────────────────────────────────
# The compose stack pulls a pre-built image from GHCR (built by CI on
# every main-branch merge). Default: ghcr.io/omnigent-ai/omnigent-server.
@@ -153,6 +159,13 @@ POSTGRES_PASSWORD=change-me-please
# ── Optional OIDC tuning ─────────────────────────────────
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
#
# Absolute lifetime (days) of login-issued refresh grants — how long a
# host/CLI that logged in with `omnigent login` can keep renewing its
# access before a human must log in again. Default 30. Unattended hosts
# renew automatically within this window; each host replica needs its
# own login (refresh tokens rotate — shared copies revoke each other).
# OMNIGENT_GRANT_MAX_LIFETIME_DAYS=30
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
#
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
+21
View File
@@ -39,6 +39,27 @@ Reset everything (drops the DB and the artifact store):
docker compose down -v
```
## Release features
Release features are deployment-wide and off by default. Enable one or more
with the comma-separated `OMNIGENT_FEATURES` variable in `.env`, then recreate
the server container:
```dotenv
OMNIGENT_FEATURES=usage_page
```
```bash
docker compose up -d
curl -s http://localhost:8000/v1/info | jq '.features'
```
Known keys and their lifecycle are documented in
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md). Unknown keys fail
server startup so a typo cannot silently produce the wrong rollout. To roll
back, remove the key (or empty the variable), run `docker compose up -d` again,
and reload the web app.
## Multi-user mode (accounts — default)
Built-in accounts auth: no IdP to register, no proxy to host.
+13
View File
@@ -47,3 +47,16 @@ allowed_domains:
# the built-in defaults (20 files / 256 MiB total).
# copy_max_files: 20
# copy_max_total_bytes: 268435456
# Branding / white-labeling. Customize the app name, landing heading, and
# logos shown in the web UI. Logo files must live under branding-assets/ beside
# this config file; served pre-auth so the login screen is branded too. Any unset
# field keeps its built-in default. See deploy/README.md#branding-white-labeling.
# branding:
# app_name: "Acme Agent" # tab title, sidebar wordmark, login screen
# heading: "How can I help?" # landing hero; "" hides it, omit for the default
# logo: # a bare string sets `main`; or per-variant:
# main: logo.png # hero / primary mark
# loading: loading.webp # working indicator (falls back to main)
# favicon: favicon.png # browser-tab icon
# powered_by: true # "Powered by Omnigent" credit (only when branded); false to hide
+3
View File
@@ -62,6 +62,9 @@ services:
ARTIFACT_DIR: /data/artifacts
HOST: 0.0.0.0
PORT: "8000"
# Comma-separated deployment-wide release features. Empty means all
# release features are off; see .env.example for the known keys.
OMNIGENT_FEATURES: "${OMNIGENT_FEATURES:-}"
# Anchor the server's data dir on the persistent volume so
# file-backed operator config survives container restarts:
# the admin roster (/data/admins) and allowed-domains file
+6
View File
@@ -304,6 +304,11 @@ def _build_routing(
return _build_local_llm_routing_client(server_llm), settings
def _resolve_execution_timeout(cfg: dict[str, Any]) -> int:
"""Return the configured execution limit or the RuntimeCaps default."""
return int(cfg.get("execution_timeout") or 7200)
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
"""Resolve config if needed, wire the stores, and build the app.
@@ -374,6 +379,7 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
routing_client, routing_settings = _build_routing(cfg, server_llm)
caps = RuntimeCaps(
execution_timeout=_resolve_execution_timeout(cfg),
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
+48
View File
@@ -91,6 +91,22 @@ Apply your chosen issuer with `kubectl apply -f <file>`. Without it, cert-manage
logs `IssuerNotFound` and no certificate is issued (the server still runs — only
TLS is affected).
## Release features
Release features are deployment-wide and off by default. Set the
comma-separated `OMNIGENT_FEATURES` value in `base/configmap.yaml`, apply your
Kustomize target, and restart the Deployment so every pod receives one fresh
startup snapshot:
```bash
kubectl kustomize deploy/kubernetes/base/ | kubectl apply -f -
kubectl rollout restart deployment/omnigent
kubectl rollout status deployment/omnigent
```
Use the same restart after removing a feature for rollback. See
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known keys.
## Deploy with an external database
Use this path when you have a managed Postgres (RDS, Cloud SQL, Neon, etc.).
@@ -270,6 +286,38 @@ kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
Both are detailed in
[`overlays/sandbox-runners/README.md`](overlays/sandbox-runners/README.md#server-auth-managed-hosts).
## Deploy with ArgoCD
The `overlays/argocd/` overlay adds ArgoCD safety annotations (`Prune=false` on
stateful resources, `ignoreDifferences` for operator-managed Secrets) onto
`sandbox-runners`. ArgoCD renders Kustomize natively — no plugin needed.
**Prerequisites:** fork the repo and replace the placeholder values in
`base/secret.yaml` in your fork (ArgoCD reads from Git, not your disk). The
default `accounts` auth provider refuses the managed runner dial-back — use
header/OIDC auth or single-user (see
[sandbox-runners README § Server auth](overlays/sandbox-runners/README.md#server-auth-managed-hosts)).
```bash
# 1. Edit application.yaml — set repoURL to your fork, targetRevision to your
# branch — then apply:
kubectl apply -f deploy/kubernetes/overlays/argocd/application.yaml
# 2. Wait for ArgoCD to create the runner namespace (up to 3 min without a webhook):
kubectl wait --for=jsonpath='{.status.phase}'=Active \
namespace/omnigent-sandboxes --timeout=300s
# 3. Create the harness-credentials Secret (see sandbox-runners README):
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
--from-literal=OPENAI_API_KEY=sk-...
```
For production, manage `omnigent-creds` with
[sealed-secrets](https://github.com/bitnami-labs/sealed-secrets) or
[external-secrets](https://external-secrets.io/). See
[`overlays/argocd/README.md`](overlays/argocd/README.md) for the full guide.
## Verify the deployment
Check the rollout and reach the server without a public domain:
+2
View File
@@ -8,6 +8,8 @@ data:
HOST: "0.0.0.0"
PORT: "8000"
ARTIFACT_DIR: "/data/artifacts"
# Comma-separated release features; empty keeps every feature off.
OMNIGENT_FEATURES: ""
OMNIGENT_ADMIN_CREDENTIALS_PATH: "/data/admin-credentials"
OMNIGENT_AUTH_ENABLED: "1"
OMNIGENT_AUTH_PROVIDER: "accounts"
+135
View File
@@ -0,0 +1,135 @@
# ArgoCD overlay
Deploy Omnigent with the kubernetes sandbox provider via ArgoCD. This overlay
adds safety annotations onto the
[`sandbox-runners`](../sandbox-runners/README.md) overlay:
- **`Prune=false`** on Namespaces and the artifact PVC, so an accidental prune
or Application deletion does not cascade to operator-created Secrets and
runner Pods.
- **Ingress in wave 1**, so its health check (which requires an ingress
controller) does not gate the rest of the sync.
ArgoCD's built-in kind ordering already applies resources in dependency order
(Namespace → SA → Role → ConfigMap → Secret → Service → Deployment → Ingress),
so explicit sync-wave ordering for every resource is unnecessary.
ArgoCD renders Kustomize natively — no plugin or Helm chart needed.
## Quick start
1. **Fork the repo** — ArgoCD reads from Git, not your local disk. All edits
below go into your fork and must be committed and pushed to the branch
`targetRevision` names (default: `HEAD` / your default branch).
2. **Replace placeholder secrets**`base/secret.yaml` ships `changeme`
values. In your fork, set real values and commit:
```yaml
# deploy/kubernetes/base/secret.yaml
DATABASE_URL: "postgresql+psycopg://user:pass@your-db-host:5432/omnigent"
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "<run: openssl rand -hex 32>"
```
For production, manage `omnigent-secrets` externally (sealed-secrets or
external-secrets) and remove `secret.yaml` from the overlay render with a
`$patch: delete` — see `openshift/kustomization.yaml:12-20` for the pattern.
The Application's `ignoreDifferences` entry prevents `selfHeal` from
reverting out-of-band edits to this Secret's data.
3. **Configure server auth** — the default `accounts` provider refuses the
managed runner dial-back (`403`). Front the server with **header or OIDC
auth**, or run single-user. See
[`sandbox-runners/README.md` § Server auth](../sandbox-runners/README.md#server-auth-managed-hosts).
4. **Set your domain** *(optional)* — replace `omnigent.example.com` in
`base/ingress.yaml`. To skip the Ingress entirely, add a `$patch: delete`
in your fork's overlay (see `openshift/kustomization.yaml:12-20` for the
pattern — do not delete `base/ingress.yaml` itself, as it is shared by all
overlays).
5. **Edit and apply the Application CR:**
```bash
# In application.yaml, set repoURL to your fork and targetRevision to
# the branch you pushed to:
kubectl apply -f deploy/kubernetes/overlays/argocd/application.yaml
```
6. **Wait for the sync** — ArgoCD creates the namespaces asynchronously (up
to 3 minutes without a webhook). Wait before creating the harness Secret:
```bash
kubectl wait --for=jsonpath='{.status.phase}'=Active \
namespace/omnigent-sandboxes --timeout=300s
```
7. **Create the harness-credentials Secret** — LLM API keys for runner Pods.
Not in Git (credentials don't belong there):
```bash
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
--from-literal=OPENAI_API_KEY=sk-...
```
For production, manage this with
[sealed-secrets](https://github.com/bitnami-labs/sealed-secrets) or
[external-secrets](https://external-secrets.io/).
## What ArgoCD does not create
ArgoCD does not *create* these resources — but it **owns the namespaces they
live in**. Deleting the Application (with the default finalizer) deletes both
namespaces and garbage-collects everything inside them, including:
- **`omnigent-creds` Secret** (step 7 above) — without it, runner Pods stall
in `CreateContainerConfigError`. See the
[sandbox-runners README](../sandbox-runners/README.md#apply) for which keys
to set.
- **OIDC / external-auth Secrets** — if you front the server with OIDC, create
the provider Secret separately (see the
[base README](../../README.md#use-your-own-idp-instead-oidc--optional)).
The `Prune=false` annotations protect Namespaces and the PVC during **sync**
(accidental prune from a Git rename), but the Application finalizer bypasses
them on **deletion**. To make `kubectl delete application` orphan resources
instead of cascading, remove the `resources-finalizer.argocd.argoproj.io`
finalizer from `application.yaml`.
## What automated sync does
- **`prune: true`** — resources that leave Git are deleted from the cluster on
the next sync. `Prune=false` annotations on Namespaces and the PVC exempt
them.
- **`selfHeal: true`** — manual cluster edits are reverted to match Git.
`ignoreDifferences` on `omnigent-secrets` and `omnigent-artifacts` exempts
their data, so out-of-band credential edits and volume expansions are kept.
- **Deleting the Application** — with the finalizer, deletes both namespaces,
the artifact PVC, and everything inside them. Without it, orphans everything.
## Customizing
Fork the repo, edit, commit, and push — ArgoCD picks up changes on the next
sync. Common adjustments:
- **Sandbox config** — `../sandbox-runners/sandbox-config.yaml` (namespace,
image, node selector, resource limits, PVC mounts). Note: changes to
ConfigMaps require a Pod restart to take effect (the server reads config at
startup). Use `configMapGenerator` with a name-suffix hash to trigger an
automatic rollout, or restart the Deployment manually after sync.
- **Server resources** — `../../base/deployment.yaml`.
- **Ingress** — `../../base/ingress.yaml` (hostname, TLS, annotations). To
remove the Ingress, add a `$patch: delete` in the overlay (see
`openshift/kustomization.yaml`).
- **In-cluster Postgres** — use `overlays/openshift-postgres/` as a reference
for composing two overlays that share a base; adding `../postgres/` as a
direct resource causes a duplicate-base error. Alternatively, apply the
Postgres StatefulSet separately.
## ApplicationSet (multi-environment)
For staging/production splits, use an ArgoCD
[ApplicationSet](https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/)
with a list generator. Point each entry at a different `targetRevision` (branch)
or fork the overlay directory per environment with its own config values.
@@ -0,0 +1,79 @@
---
# Sample ArgoCD Application CR for deploying Omnigent with the kubernetes
# sandbox provider. Fork the repo, edit config/secrets in your fork, then
# set repoURL and targetRevision below. Apply to the argocd namespace.
#
# ArgoCD renders the Kustomize output natively — no Helm chart or plugin needed.
#
# TWO SECRETS ARE NOT IN GIT and must be created out of band (or via
# sealed-secrets / external-secrets):
#
# 1. omnigent-secrets — DATABASE_URL + cookie secret (see base/secret.yaml
# for the keys; replace the placeholder values in your fork, or manage
# the Secret externally and remove secret.yaml from the overlay render
# with a $patch: delete — see openshift/kustomization.yaml for the
# pattern).
# 2. omnigent-creds — harness LLM API keys for runner Pods (see the
# sandbox-runners README).
#
# DELETION WARNING: the resources-finalizer below means
# `kubectl delete application omnigent` deletes every tracked resource,
# including both Namespace objects and the artifact PVC. The overlay's
# Prune=false annotations prevent accidental pruning during sync, but the
# finalizer bypasses them on Application deletion. Remove the finalizer
# if you want `kubectl delete application` to orphan resources instead of
# cascading.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: omnigent
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/omnigent-ai/omnigent.git
targetRevision: HEAD
path: deploy/kubernetes/overlays/argocd
destination:
# Resources set their own namespaces explicitly (omnigent and
# omnigent-sandboxes), so destination.namespace is not injected.
server: https://kubernetes.default.svc
ignoreDifferences:
# The checked-in secret.yaml ships placeholder values. Operators replace
# them out of band (kubectl edit, sealed-secrets, external-secrets), and
# selfHeal must not revert those edits. Without this, selfHeal
# continuously overwrites live credentials with the placeholder.
#
# The pointer targets /data, not /stringData, because ArgoCD normalizes
# stringData into base64 /data on the live object before diffing.
- group: ""
kind: Secret
name: omnigent-secrets
namespace: omnigent
jsonPointers:
- /data
# The API server mutates spec.resources.requests.storage on PVC creation
# (rounding, defaulting). selfHeal would report a perpetual diff.
- group: ""
kind: PersistentVolumeClaim
name: omnigent-artifacts
namespace: omnigent
jsonPointers:
- /spec/resources/requests/storage
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
# Honour the ignoreDifferences entries above during sync, not just
# during diff. Without this, a manual sync still overwrites the live
# secret values even though the diff view hides them.
- RespectIgnoreDifferences=true
retry:
limit: 3
backoff:
duration: 10s
factor: 2
maxDuration: 3m
@@ -0,0 +1,46 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# ArgoCD overlay for the kubernetes sandbox provider. Adds safety annotations
# (Prune=false on stateful resources, Ingress in a late wave) onto the
# sandbox-runners overlay. ArgoCD's built-in kind ordering already sequences
# Namespace → SA → Role → ConfigMap → Secret → Service → Deployment → Ingress,
# so explicit sync waves are not needed for ordering — only to prevent the
# Ingress health gate from blocking the sync on clusters without a controller.
resources:
- ../sandbox-runners
patches:
# Namespaces and the artifact PVC must survive Application deletion and
# accidental prune (a rename in base/ or a stale targetRevision). Without
# Prune=false, `kubectl delete application omnigent` cascades through the
# finalizer to both namespaces and everything inside them — including the
# operator-created omnigent-creds Secret and any pvc_mounts claims.
- target:
kind: Namespace
patch: |
- op: add
path: /metadata/annotations/argocd.argoproj.io~1sync-options
value: Prune=false
- target:
kind: PersistentVolumeClaim
patch: |
- op: add
path: /metadata/annotations/argocd.argoproj.io~1sync-options
value: Prune=false
# The Ingress depends on an ingress controller (nginx by default) and
# cert-manager. ArgoCD scores an Ingress without status.loadBalancer as
# Progressing. Wave 1 (everything else is implicit wave 0) means no later
# sync wave gates on its health, so the *sync* completes. The Application
# itself may still report Progressing indefinitely on a cluster without a
# controller — with automated + selfHeal, that keeps the Application in a
# perpetual reconcile loop (harmless but noisy). Install the controller or
# add a custom health check that treats the Ingress as healthy.
- target:
kind: Ingress
patch: |
- op: add
path: /metadata/annotations/argocd.argoproj.io~1sync-wave
value: "1"
@@ -1,42 +1,43 @@
# Kubernetes sandbox runners (on-demand host Pods)
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.
@@ -1,10 +1,15 @@
---
# Namespaced Role granting the server EXACTLY what the entrypoint-as-host
# launcher calls — nothing more (no watch, no pods/exec). Lives in the DEDICATED
# runner namespace `omnigent-sandboxes` and is bound to the omnigent-server SA
# (in `omnigent`) via the cross-namespace rolebinding.yaml. Because the grant is
# a namespaced Role here, it can ONLY ever touch objects in `omnigent-sandboxes`,
# never the server/DB Pods or Secrets in `omnigent`.
# Namespaced Role granting what the entrypoint-as-host launcher needs (no watch,
# no pods/exec). Lives in the DEDICATED runner namespace `omnigent-sandboxes`
# and is bound to the omnigent-server SA (in `omnigent`) via the cross-namespace
# rolebinding.yaml. Because the grant is a namespaced Role here, it can ONLY
# ever touch objects in `omnigent-sandboxes`, never the server/DB Pods or
# Secrets in `omnigent`.
#
# pods:create is retained temporarily for the bare-Pod → Job migration (see
# TODO below). secrets:get is deliberately withheld — the launcher never reads
# Secrets back, and the harness-credentials Secret is operator-managed.
# pods/exec is withheld — the host is an entrypoint, not exec'd into.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
@@ -14,34 +19,31 @@ metadata:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: server
rules:
# Manage the lifecycle of runner Pods, scoped to exactly what the launcher
# calls: provision_managed_host() creates them, the pod-start wait reads them
# (read_namespaced_pod is a `get`), and terminate() deletes them. The launcher
# never watches Pods, so `watch` is omitted. NOTE: there is deliberately NO
# `pods/exec` grant — the host runs as the Pod's OWN entrypoint
# (`omnigent host` under a PID-1 reaper), so the server never execs into a
# running container. Dropping exec removes the most powerful grant a
# compromised server could abuse (arbitrary in-Pod command execution).
# Manage the lifecycle of runner Jobs. The launcher creates a batch/v1 Job
# (which spawns a child Pod), reads the Job's child Pod to poll start
# readiness, and deletes the Job (cascading to its Pods) at terminate.
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "delete"]
# The launcher lists Pods by job-name label to find the Job's child Pod,
# then reads it to poll for Running phase. create/delete are retained so
# old-server (bare Pod) + new-Role doesn't break, and so terminate() can
# fall back to deleting a bare Pod created before the Job migration.
# TODO(v0.29): remove create/delete once all runners have rolled past v0.28.
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "delete"]
# Start-failure diagnostics ONLY: when a Pod won't start, the launcher tails
# the failed container's log (e.g. the init container's `git clone` error) so
# the launch error names WHAT failed instead of a generic timeout. Read-only.
verbs: ["create", "list", "get", "delete"]
# Start-failure diagnostics ONLY: tails the failed container's log.
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
# The per-launch token rides a per-Pod Secret (referenced by the Pod's
# `secretKeyRef`), so the launch token never enters the Pod spec or any
# audit-logged surface. The launcher creates that Secret at provision and
# deletes it alongside the Pod at terminate — hence create + delete (no get:
# the launcher never reads Secrets back, and the harness-credentials Secret is
# operator-managed, not touched here).
# The per-launch token rides a per-Job Secret (referenced by the Pod's
# `secretKeyRef`). The launcher creates that Secret before the Job and
# deletes it alongside the Job at terminate.
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "delete"]
# Surface scheduler/kubelet events (FailedScheduling, Failed pull, …) in the
# provider's error messages when a Pod won't become ready.
# Surface scheduler/kubelet events in the provider's error messages.
- apiGroups: [""]
resources: ["events"]
verbs: ["list"]
+2 -2
View File
@@ -404,6 +404,6 @@ upload, foreground streaming, attach, terminate, env passthrough, error handling
and the managed-config parsing:
```bash
uv pip install -e '.[openshell,dev]'
pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
uv sync --extra openshell --group test
uv run --no-sync pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
```
+8
View File
@@ -73,6 +73,14 @@ steps below are validated end-to-end:
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Release features
In the Omnigent service's **Variables** tab, set `OMNIGENT_FEATURES` to a
comma-separated enabled set such as `usage_page`. Railway redeploys the service
automatically. Remove the key from the value to roll back, then reload the web
app. See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known
keys.
## Use your own IdP instead (OIDC)
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
+88 -12
View File
@@ -17,9 +17,11 @@
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
> flow and never mounts these routes).
> `/oauth/device/*` consent routes are unmounted unless it is truthy), and
> then only in **accounts** mode (OIDC delegates login to the IdP via the
> cli-ticket flow and never mounts the consent routes). The token/revoke
> half (`/oauth/token`, `/oauth/revoke`) mounts **unconditionally** in both
> accounts and OIDC modes: login-issued refresh grants (below) need it.
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
@@ -182,15 +184,20 @@ approved it.
### Router `omnigent/server/routes/device_auth.py`
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
cli-ticket flow and never mounts these routes; header mode has no
server-mintable identity — see `create_device_auth_router`, which raises if
constructed for any other source). The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the router
mount is gated. This router **owns** `mint_delegated_token` and
`DELEGATED_SCOPE`.
The RFC 8628 consent surface (`/oauth/device/*`) is mounted in `app.py`
only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy** (opt-in,
**default-off**), and then **only in `accounts` mode** (the in-browser
consent needs the accounts login page; header mode has no server-mintable
identity — see `create_device_auth_router`, which raises if constructed for
any other source). The token/revoke half is factored into
`create_oauth_token_router` and mounts **unconditionally** in both accounts
and OIDC modes — login-issued refresh grants need `/oauth/token` even where
the device flow is off; a standalone mount refuses the `device_code` grant
type with `unsupported_grant_type`. The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the consent
mount is gated. This router also **owns** `mint_delegated_token` and
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
`mint_session_token` / `mint_session_cookie`).
- `POST /oauth/device/authorize`**public** (rate-limited). Generates a
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
@@ -355,3 +362,72 @@ stateless. This added invariant is the main thing for reviewers to scrutinize.
- Applying the same delegated grant to other non-browser clients (the CLI could
use it too, superseding the in-memory `_cli_tickets` store).
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
## Login-issued refresh grants
The fix for unattended hosts dying at session-JWT expiry (a host
authenticated via `omnigent login` previously had **no renewal path**
the stored `{token, user_id, expires_at}` record simply lapsed, default
8 h, and the next tunnel reconnect got a misleading 403).
- **Issuance** — both interactive login flows create a grant born
`redeemed` (`DeviceGrantStore.create_redeemed_grant`; the interactive
login *is* the consent step, so no device-code dance): the OIDC
cli-ticket fulfillment always, and accounts `POST /auth/login` when the
body carries `issue_refresh: true` (sent by the CLI, never the web
form — a browser must not receive refresh material). The raw refresh
token rides back once (`/auth/cli-poll` / the login response) as an
optional `refresh_token` key — old CLIs ignore it, new CLIs against old
servers see it absent. `client_id` is `"omnigent-cli"`.
- **Authority** — a refreshed login-grant token carries `grant_id` (so
revocation still kills it) but **no `scope` claim**, so the delegated
path allowlist does not apply: it renews the session JWT and keeps that
same authority. Scoping it like a third-party device grant instead made
every non-allowlisted route (`/v1/usage`, `/v1/scheduled-tasks`,
`/v1/policy-registry`) 401 after the first refresh. `LOGIN_GRANT_CLIENT_ID`
is the marker and is **reserved**`/oauth/device/authorize` refuses a
request naming it, so a device client cannot self-declare into this class.
- **No rotation** — login grants return the SAME refresh token on every
renewal. Rotation + reuse detection is right for a browser-adjacent
third-party client, but for an unattended host it turns any ambiguous
network failure (lost response, crash between the server committing and
the client persisting) into a permanently revoked grant. Only the
short-lived access token is renewed; revocation and the absolute lifetime
cap still bound exposure. Device grants keep rotating.
- **Renewal** — `omnigent.cli_auth.refresh_stored_token` POSTs
`grant_type=refresh_token`, persists the result, and returns the
fresh access token. The runner/host auth-token factory
calls it when the stored token lapses, and the host tunnel rebuilds
headers through that factory on every reconnect — so an unattended
host renews itself for the grant's lifetime. Refreshes on one machine
are serialized by an advisory file lock with a re-check after acquire,
so a losing racer picks up the winner's rotated pair instead of
replaying the stale token (which reuse detection would punish by
revoking the grant).
- **One grant per host replica (recommended)** — since login grants do
not rotate, N replicas sharing one token file no longer revoke each
other. A per-replica login is still preferred so a single host can be
revoked without cutting off the rest.
- **Lifetime** — the absolute grant lifetime stays 30 days by default;
`OMNIGENT_GRANT_MAX_LIFETIME_DAYS` lets an operator extend it
deliberately for long-lived unattended hosts. Invalid values fall back
to the default (never fail open to unbounded).
- **Client-secret interaction** — `OMNIGENT_DEVICE_CLIENT_SECRET` gates
the endpoints that mint from an ephemeral code (device authorize + the
`device_code` exchange). Refresh and revoke are NOT gated: the presented
refresh/access token is itself the credential, and a CLI renewing its own
login has no way to carry that secret (gating it made every automatic
refresh 401 in a Slack-serving deployment).
- **Housekeeping** — `/oauth/token` opportunistically purges expired and
aged-out grants. The device flow purges on `authorize`, which a
standalone token-router mount does not have, so login-grant rows would
otherwise accumulate one per login.
### Known limitation
General CLI commands (`omnigent usage`, session commands, direct
remote-URL chat) still read the stored token without attempting a refresh —
only the host/runner auth-token factory renews today. An expired login with
valid refresh material therefore leaves those commands unauthenticated
until something on the host path renews the shared file. Wiring the
remaining CLI entrypoints is follow-up work.
+41
View File
@@ -0,0 +1,41 @@
# Release feature flags
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.
+26
View File
@@ -68,6 +68,32 @@ 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 one **Python** command hook — isolated interpreter, module entrypoint, JSON payload on stdin — and time its whole lifetime |
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
lifetime is user-visible latency. Read this number as *"what a hook costs if it
is Python"*.
It is **not** the per-chunk streaming cost, and treating it as one leads
straight to wasted work. The hooks that fire per chunk (`MessageDisplay`) and
per tool call (`evaluate-policy`) were deliberately moved off the interpreter —
a `/bin/sh` appender and a `curl` to the runner's relay — and
`test_message_display_shell_command_round_trips` pins that by asserting
`"python"` is absent from the installed command. What still pays this number is
the per-turn set (`SessionStart` / `Stop` / `UserPromptSubmit` / `PreCompact` /
`Task*`), the `PostToolUse` `TodoWrite`+`TaskUpdate` matchers, and the policy
hook's Python fallback before the relay is up. So the journey's real job is to
keep the argument for staying off the interpreter measurable. 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
`tests/test_claude_native_message_display_hook.py`.
### Full-turn (runner + mock LLM)
These drive a real agent turn end-to-end — `POST …/events` → server → **runner**
+219 -9
View File
@@ -44,6 +44,12 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
@@ -97,6 +103,9 @@ class Journey:
per op, so 100+ iterations would blow the CI time budget; they cap at a
few samples per run and lean on ``--runs`` for repeats. ``None`` (HTTP
journeys) means no cap.
:param skip_warmup: When ``True``, the warmup phase is skipped regardless
of ``--warmup``. Useful for expensive journeys where even a single
warmup iteration would waste significant time.
:param description: Human-readable one-liner for ``--list``.
"""
@@ -110,6 +119,7 @@ class Journey:
needs_runner: bool = False
needs_host: bool = False
max_iterations: int | None = None
skip_warmup: bool = False
description: str = ""
async def run_setup(self, env: BenchEnvironment) -> JourneyContext:
@@ -131,10 +141,13 @@ def _failure_reason(exc: Exception) -> str:
"""Classify an exception into a stable failure-breakdown label.
HTTP status errors key off their status code (``"HTTP 500"``) so the same
server error groups across ops; anything else keys off its class name.
server error groups across ops; RuntimeErrors include the message so CI
failure breakdowns show the actual cause; anything else keys off class name.
"""
if isinstance(exc, httpx.HTTPStatusError):
return f"HTTP {exc.response.status_code}"
if isinstance(exc, RuntimeError):
return f"RuntimeError: {exc}"
return exc.__class__.__name__
@@ -235,7 +248,8 @@ async def run_latency(
except Exception as exc: # noqa: BLE001 — a setup failure is a recorded data point
return _setup_failed_result(exc)
try:
for _ in range(warmup):
effective_warmup = 0 if journey.skip_warmup else warmup
for _ in range(effective_warmup):
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.run_prepare(env, ctx)
await journey.measure(env, ctx)
@@ -681,6 +695,12 @@ async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext)
# ── policy evaluate ──────────────────────────────────────────
def _bench_policy_allow(_event: dict) -> dict: # type: ignore[type-arg]
"""Benchmark policy function: always ALLOW. Self-contained in this module."""
return {"result": "allow"}
_POLICY_EVALUATE_PAYLOAD = {
"event": {
"type": "PHASE_TOOL_CALL",
@@ -707,15 +727,24 @@ async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
import yaml
# Build a bundle like BenchEnvironment._agent_bundle but with a policy
# declared so any_policies_apply is true and the full engine runs.
executor: dict[str, object] = {
"type": "omnigent",
"model": env.model,
"config": {"harness": env.harness},
}
config: dict[str, object] = {
"spec_version": 1,
"name": "bench-policy-agent",
"prompt": "benchmark",
"executor": executor,
"guardrails": {
"policies": {
"allow_all": {
"type": "function",
"on": ["tool_call"],
"function": "tests.runtime.policies.conftest._always_allow",
"function": "dev.benchmarks.omnigent.journeys._bench_policy_allow",
}
}
},
@@ -728,16 +757,17 @@ async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
tar.addfile(info, io.BytesIO(payload))
bundle = buf.getvalue()
# Register the agent + create a session in one call via the bundle upload
# path (``POST /v1/sessions`` multipart). ``/v1/agents`` is GET-only.
resp = await env.client.post(
"/v1/agents",
"/v1/sessions",
data={"metadata": "{}"},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
)
resp.raise_for_status()
agent_id = resp.json()["id"]
session_resp = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
session_resp.raise_for_status()
session_id = session_resp.json()["id"]
body = resp.json()
# Bundle upload returns ``session_id`` (not ``id``).
session_id = body.get("session_id") or body["id"]
# Warm the spec + policy caches — the measured iteration is steady-state.
for _ in range(2):
@@ -759,6 +789,157 @@ async def _measure_policy_evaluate(env: BenchEnvironment, ctx: JourneyContext) -
resp.raise_for_status()
# ── CLI startup (omnigent polly against the local bench server) ──────────────
# Signal that the REPL is ready — the last spinner message before the prompt.
# polly (omnigent run) emits this just before the agent REPL appears.
_CLI_STARTUP_READY_SIGNAL = "Launching your agent"
# Per-attempt timeout. With the bench host daemon pre-running (needs_host=True),
# polly reuses it; remaining work is session + runner connect ~5-20s on CI.
_CLI_STARTUP_TIMEOUT_S = 60
# ~5s per attempt; cap so a large --iterations stays in budget.
_CLI_STARTUP_MAX_ITERATIONS = 3
async def _prepare_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
"""Stop stale daemons before each timed cli_startup iteration.
A leftover host daemon from the previous iteration causes the next
``omnigent polly`` to fail with "runner tunnel rejection (HTTP 401)"
or "host is on another replica". Runs outside the latency timer.
"""
del env
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
if omnigent_bin is None:
return
await asyncio.to_thread(
subprocess.run,
[omnigent_bin, "stop"],
capture_output=True,
timeout=15,
check=False,
)
async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
"""Time ``omnigent polly --server`` from invocation to REPL ready.
Spawns ``omnigent polly --server <local>`` via pexpect and times until
``"Launching your agent…"`` appears — the last spinner message before the
agent REPL. Using polly (the bundled openai-agents harness) avoids any
external binary dependency while exercising the same startup path as
``omnigent claude``: daemon start, session create, runner launch, and
runner connect.
Requires ``pexpect``. No external LLM binary needed.
:param env: Benchmark environment — ``env.base_url`` is the local server URL.
:param _ctx: Unused (no setup context).
:raises RuntimeError: On timeout or process exit before the ready signal.
"""
try:
import pexpect
except ImportError as exc:
raise RuntimeError(
"pexpect is required for cli_startup. Install with: pip install pexpect"
) from exc
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
if omnigent_bin is None:
raise RuntimeError("omnigent binary not found. Set OMNIGENT_BIN or add omnigent to PATH.")
child = pexpect.spawn(
omnigent_bin,
args=["polly", "--server", env.base_url],
timeout=_CLI_STARTUP_TIMEOUT_S,
encoding="utf-8",
codec_errors="ignore",
env=dict(os.environ),
)
try:
idx = child.expect([pexpect.TIMEOUT, pexpect.EOF, _CLI_STARTUP_READY_SIGNAL])
if idx == 0:
raise RuntimeError(
f"Timed out after {_CLI_STARTUP_TIMEOUT_S}s waiting for "
f"{_CLI_STARTUP_READY_SIGNAL!r}"
)
if idx == 1:
output = (child.before or "").strip()
raise RuntimeError(
f"Process exited before {_CLI_STARTUP_READY_SIGNAL!r}. "
f"Last output: {output[-200:]!r}"
)
child.sendline("/exit")
child.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=10)
finally:
if child.isalive():
child.terminate(force=True)
# ── native hook spawn (no server involved) ───────────────────
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
# lifetime is user-visible latency. This journey times that lifetime for a
# Python hook — isolated interpreter, module entrypoint, JSON payload on stdin —
# which is the cost of ANY hook the bridge installs as a `python -m` command.
#
# It is NOT the per-chunk streaming path. The hooks that fire per chunk
# (MessageDisplay) and per tool call (evaluate-policy) were deliberately moved
# off the interpreter — a /bin/sh appender and a curl to the runner's relay
# respectively — and tests pin that (`test_message_display_shell_command_round_trips`
# asserts "python" is absent from the installed command). What still pays this
# is the per-turn set (SessionStart / Stop / UserPromptSubmit / PreCompact /
# Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and the policy hook's
# Python fallback when the relay is not yet up. So read this number as
# "what a hook costs if it is Python", and as the standing argument for keeping
# the hot paths off it — not as a per-chunk cost. The import-graph side is
# pinned by tests/test_claude_native_message_display_hook.py.
_HOOK_SPAWN_PAYLOAD = json.dumps(
{
"hook_event_name": "MessageDisplay",
"message_id": "bench-message",
"index": 0,
"final": False,
"delta": "benchmark chunk",
}
).encode()
async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
"""A throwaway bridge dir for the hook's appended deltas file."""
del env
return tempfile.mkdtemp(prefix="omnigent-bench-hook-")
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Spawn one Python hook subprocess and wait, as Claude Code would."""
del env
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-m",
"omnigent.claude_native_message_display_hook",
"--bridge-dir",
str(ctx),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate(_HOOK_SPAWN_PAYLOAD)
if proc.returncode != 0:
raise RuntimeError(
f"hook exited {proc.returncode}: {stderr.decode('utf-8', 'replace')[:200]}"
)
async def _teardown_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Remove the throwaway bridge dir."""
del env
shutil.rmtree(str(ctx), ignore_errors=True)
# ── registry ─────────────────────────────────────────────────
ALL_JOURNEYS: dict[str, Journey] = {
@@ -904,9 +1085,38 @@ ALL_JOURNEYS: dict[str, Journey] = {
max_iterations=_RUNNER_FS_MAX_ITERATIONS,
description="GET .../environments/default/filesystem/{path} — runner file read proxy.",
),
Journey(
name="native_hook_spawn",
kind="latency",
measure=_measure_hook_spawn,
setup=_setup_hook_spawn,
teardown=_teardown_hook_spawn,
description=(
"Spawn one Python command hook (isolated interpreter, module "
"entrypoint) and time its whole lifetime — what any `python -m` "
"hook costs Claude's blocked TUI. Not the per-chunk path: that "
"one is a /bin/sh appender."
),
),
Journey(
name="cli_startup",
kind="latency",
measure=_measure_cli_startup,
prepare=_prepare_cli_startup,
max_iterations=_CLI_STARTUP_MAX_ITERATIONS,
skip_warmup=True,
description=(
"Spawn `omnigent polly --server` and time invocation → REPL ready "
"(daemon + session + runner connect). No LLM call needed. "
"Requires pexpect."
),
),
)
}
# Registry alias — kept for callers that enumerate opt-in journeys explicitly.
OPT_IN_JOURNEYS: dict[str, Journey] = {}
def resolve_journeys(names: list[str] | None) -> list[Journey]:
"""Resolve requested journey *names* (or all when ``None``/empty).
+190
View File
@@ -0,0 +1,190 @@
"""Render ``run.py`` JSON reports as GitHub-flavoured markdown result matrices.
Where ``compare.py`` renders a baseline-vs-candidate regression table, this
renders the absolute numbers of one or more standalone reports — the
journey × metric matrix a CI job appends to ``$GITHUB_STEP_SUMMARY``. Given
several reports (e.g. the nightly's sqlite / postgres / mysql legs) it also
leads with a cross-report P50 matrix so the backends can be compared side by
side.
Usage::
report_markdown.py REPORT.json [REPORT.json ...] [--title TEXT]
Prints markdown to stdout. Report labels come from each report's
``config.backend``; when two reports share a backend the filename stem is
appended to keep the columns distinguishable.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Keep table cells one-line and skimmable: a skip reason is an exception
# rendering, which can run long and embed newlines that would break the row.
_MAX_NOTE_LEN = 100
def _fmt_ms(value: object) -> str:
"""Format a millisecond metric, or ``—`` when it is absent."""
return f"{value:.1f}" if isinstance(value, (int, float)) else ""
def _fmt_rps(value: object) -> str:
"""Format a requests-per-second metric, or ``—`` when it is absent."""
return f"{value:.0f}" if isinstance(value, (int, float)) else ""
def _one_line(text: str) -> str:
"""Collapse *text* onto one bounded line so it can live in a table cell."""
flattened = " ".join(str(text).split())
if len(flattened) > _MAX_NOTE_LEN:
return flattened[: _MAX_NOTE_LEN - 1] + ""
return flattened
def _journey_note(block: dict) -> str:
"""The Notes cell for one journey block: skip reason / failure marker."""
if block.get("skipped"):
return _one_line(f"⚠️ skipped — {block.get('error', 'unknown error')}")
summary = block.get("summary") or {}
if summary and not summary.get("runs_ok"):
return "❌ every run failed"
return ""
def _runs_cell(block: dict) -> str:
"""The Runs cell: ``ok/total``, or ``—`` for a skipped journey."""
summary = block.get("summary") or {}
total = summary.get("runs_total")
if not isinstance(total, int):
return ""
return f"{summary.get('runs_ok', 0)}/{total}"
def _caption(report: dict) -> str:
"""One italic line of run context under a section heading."""
config = report.get("config") or {}
parts: list[str] = []
iterations = config.get("iterations")
runs = config.get("runs")
if iterations is not None and runs is not None:
parts.append(f"{iterations} iterations × {runs} runs")
warmup = config.get("warmup")
if warmup is not None:
parts.append(f"warmup {warmup}")
harness = report.get("harness")
if harness:
parts.append(str(harness))
sha = report.get("git_sha")
if sha:
parts.append(f"`{str(sha)[:8]}`")
return f"_{' · '.join(parts)}_" if parts else ""
def _report_section(label: str, report: dict) -> list[str]:
"""Markdown lines for one report: heading, caption, journey × metric table."""
lines = [f"### {label}", ""]
caption = _caption(report)
if caption:
lines.extend([caption, ""])
lines.extend(
[
"| Journey | Mean ms | P50 ms | P95 ms | P99 ms | Req/s | Runs | Notes |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
]
)
for name, block in (report.get("journeys") or {}).items():
summary = block.get("summary") or {}
lines.append(
f"| {name} "
f"| {_fmt_ms(summary.get('avg_mean_ms'))} "
f"| {_fmt_ms(summary.get('avg_p50_ms'))} "
f"| {_fmt_ms(summary.get('avg_p95_ms'))} "
f"| {_fmt_ms(summary.get('avg_p99_ms'))} "
f"| {_fmt_rps(summary.get('avg_rps'))} "
f"| {_runs_cell(block)} "
f"| {_journey_note(block)} |"
)
lines.append("")
return lines
def _journey_order(labeled_reports: list[tuple[str, dict]]) -> list[str]:
"""Union of journey names, keeping each report's insertion order."""
ordered: list[str] = []
for _, report in labeled_reports:
for name in report.get("journeys") or {}:
if name not in ordered:
ordered.append(name)
return ordered
def _cross_matrix(labeled_reports: list[tuple[str, dict]]) -> list[str]:
"""Journey × report P50 matrix so several reports compare side by side."""
labels = [label for label, _ in labeled_reports]
lines = [
"### P50 across reports",
"",
"| Journey | " + " | ".join(f"{label} P50 ms" for label in labels) + " |",
"| --- | " + " | ".join("---:" for _ in labels) + " |",
]
for name in _journey_order(labeled_reports):
cells = []
for _, report in labeled_reports:
block = (report.get("journeys") or {}).get(name) or {}
cells.append(_fmt_ms((block.get("summary") or {}).get("avg_p50_ms")))
lines.append(f"| {name} | " + " | ".join(cells) + " |")
lines.append("")
return lines
def build_markdown(labeled_reports: list[tuple[str, dict]], title: str | None = None) -> str:
"""Render *labeled_reports* as one markdown document.
:param labeled_reports: ``(label, report)`` pairs, where *report* is a
parsed ``run.py`` JSON report and *label* names it (e.g. its backend).
:param title: Optional top-level heading, e.g. ``"Benchmark results"``.
:returns: GitHub-flavoured markdown ending in a newline.
"""
lines: list[str] = []
if title:
lines.extend([f"## {title}", ""])
if len(labeled_reports) > 1:
lines.extend(_cross_matrix(labeled_reports))
for label, report in labeled_reports:
lines.extend(_report_section(label, report))
return "\n".join(lines).rstrip("\n") + "\n"
def _label_for(path: Path, report: dict, seen: set[str]) -> str:
"""Label a report by backend, disambiguating duplicates with the filename."""
backend = (report.get("config") or {}).get("backend")
label = str(backend) if backend else path.stem
if label in seen:
label = f"{label} ({path.stem})"
seen.add(label)
return label
def main(argv: list[str] | None = None) -> int:
"""CLI entry point: render the given report files to stdout."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("reports", nargs="+", type=Path, help="run.py JSON report file(s).")
parser.add_argument("--title", default=None, help="Optional top-level heading.")
args = parser.parse_args(argv)
labeled: list[tuple[str, dict]] = []
seen: set[str] = set()
for path in args.reports:
report = json.loads(path.read_text())
labeled.append((_label_for(path, report, seen), report))
sys.stdout.write(build_markdown(labeled, title=args.title))
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -2
View File
@@ -32,8 +32,7 @@ Runs from a **repo checkout** (it imports `dev.benchmarks` + `tests`), with the
harness + bench deps:
```bash
pip install -e '.[loadtest,dev,agents-sdk]'
# or: uv sync --extra loadtest --extra dev --extra agents-sdk
uv sync --extra loadtest --extra agents-sdk
```
## Run
+2 -2
View File
@@ -26,7 +26,7 @@ Writes a timestamped result set:
summary.md human-readable latency write-up (this tool)
Runs from a repo checkout only (imports ``dev.benchmarks`` + ``tests``), with
the ``[loadtest,dev,agents-sdk]`` extras. Knobs: ``--users`` (N hosts),
the ``loadtest`` and ``agents-sdk`` extras. Knobs: ``--users`` (N hosts),
``--spawn-rate``, ``--run-time``, ``--sessions-per-user``, ``--turns-per-session``,
``--reply-words``, ``--out-dir``.
"""
@@ -363,7 +363,7 @@ def main() -> int:
if importlib.util.find_spec(mod) is None:
sys.exit(
f"{pkg} not importable under {sys.executable} — install the extras: "
"pip install -e '.[loadtest,dev,agents-sdk]' (run from a repo checkout)."
"uv sync --extra loadtest --extra agents-sdk (run from a repo checkout)."
)
out_dir = _resolve_out_dir(args.out_dir)
return asyncio.run(_boot_and_run(args, out_dir))
+1 -1
View File
@@ -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` /
+10
View File
@@ -143,6 +143,16 @@ mod tests {
.find(|(k, _)| k == "OMNIGENT_DATABASE_URI")
.map(|(_, v)| v.clone());
assert_eq!(db, Some(pod.db_uri()));
let config_home = cmd
.env
.iter()
.find(|(k, _)| k == "OMNIGENT_CONFIG_HOME")
.map(|(_, v)| v.clone());
assert_eq!(config_home, Some(pod.config_dir().display().to_string()));
assert!(cmd.env.iter().all(|(k, _)| k != "HOME"));
assert!(cmd.env.iter().all(|(k, _)| !k.starts_with("XDG_")));
}
#[test]
+15 -5
View File
@@ -120,7 +120,7 @@ impl ProcSpec {
}
}
/// `pnpm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
/// `pnpm run dev --host <host> --port <p> --strictPort`, from `web/`.
/// `OMNIGENT_URL` (in the pod env) points Vite's proxy at this pod's backend.
pub fn vite(pod: &Pod) -> ProcSpec {
if let Some(profile) = &pod.profile {
@@ -128,10 +128,10 @@ impl ProcSpec {
}
ProcSpec {
program: "pnpm".into(),
// pnpm forwards script arguments directly; `--` would make Vite ignore the flags.
args: vec![
"run".into(),
"dev".into(),
"--".into(),
"--host".into(),
pod.vite_host.clone(),
"--port".into(),
@@ -151,7 +151,7 @@ mod tests {
use crate::profile::{ProcessProfile, Profile};
#[test]
fn vite_uses_configured_bind_host_but_backend_url_stays_loopback() {
fn vite_forwards_configured_host_and_port_but_backend_url_stays_loopback() {
let repo = tempdir();
let pod_dir = tempdir();
let pod = Pod::create(
@@ -167,8 +167,18 @@ mod tests {
.unwrap();
let vite = ProcSpec::vite(&pod);
let host_flag = vite.args.iter().position(|arg| arg == "--host").unwrap();
assert_eq!(vite.args[host_flag + 1], "0.0.0.0");
assert_eq!(
vite.args,
[
"run",
"dev",
"--host",
"0.0.0.0",
"--port",
"19292",
"--strictPort",
]
);
assert_eq!(pod.server_url(), "http://127.0.0.1:19191");
}
+3 -3
View File
@@ -13,7 +13,7 @@
## Global Constraints
- 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.
- connect-RPC: JSON (`Content-Type: application/json`), `verify=False`, every URL passes `_assert_loopback_url`. Reuse `antigravity_native_rpc.py` discovery (`discover_language_server_port` / `_candidate_agy_rpc_ports` / `_conversation_matches`).
- Identity: `cascadeId == conversationId == brain-dir UUID` (no separate id lookup).
@@ -80,7 +80,7 @@ def test_cancel_cascade_steps_true_on_200(monkeypatch):
assert rpc.cancel_cascade_steps(52548, "conv-uuid") is True
```
- [ ] **Step 2: Run, verify FAIL**`uv run pytest tests/test_antigravity_native_rpc.py -k "trajectory_steps or cancel_cascade" -v` → fail (undefined).
- [ ] **Step 2: Run, verify FAIL**`uv run --group test pytest tests/test_antigravity_native_rpc.py -k "trajectory_steps or cancel_cascade" -v` → fail (undefined).
- [ ] **Step 3: Implement** `get_trajectory_steps` (POST `{"cascadeId": cascade_id}` to `GetCascadeTrajectorySteps`, parse `.get("steps", [])`) and `cancel_cascade_steps` (POST `{"cascadeId": cascade_id}` to `CancelCascadeSteps`, return `resp.status_code < 400`), both via `_sync_client` + `_assert_loopback_url`, mirroring `_conversation_matches`.
- [ ] **Step 4: Run, verify PASS.**
- [ ] **Step 5: Commit** (`feat(antigravity-native): RPC client — trajectory steps + cancel`).
@@ -241,7 +241,7 @@ def test_handle_user_interaction_raises_on_500(monkeypatch):
- [ ] **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).
- [ ] **Step 4:** Commit (`refactor(antigravity-native): retire transcript forwarder + durable cursor (RPC reader supersedes)`).
---
+1 -1
View File
@@ -143,7 +143,7 @@ guardrails:
gate_pushes: false
tools:
# The two brainstorming partners — see agents/<name>/. Both reason and write,
# The two brainstorming partners — see agents/<dir>/. Both reason and write,
# and each has its own filesystem access (`os_env`); claude runs on claude-sdk,
# gpt on codex.
agents:
+2 -2
View File
@@ -245,7 +245,7 @@ prompt: |
Authoring skills: skills are prose, not code, so you author them directly
yourself — no sub-agent needed. A skill always belongs in polly's OWN skills
directory — `examples/polly/skills/<name>/SKILL.md` in this repo — NEVER the
directory — `examples/polly/skills/<dir>/SKILL.md` in this repo — NEVER the
host `~/.claude/skills/` directory. Your claude-sdk brain lists host skills
under `~/.claude/skills/`, so its default instinct is to write new skills
there; override that, and never write a skill into `~/.claude/skills/` (or any
@@ -324,7 +324,7 @@ terminals:
type: none
tools:
# Coding sub-agents — see agents/<name>/. claude_code, codex, opencode,
# Coding sub-agents — see agents/<dir>/. claude_code, codex, opencode,
# cursor, hermes, and agy are real CLI coding harnesses; pi is a headless
# multi-model worker. Each implements, reviews (cross-vendor), and explores.
agents:
+3 -3
View File
@@ -44,7 +44,7 @@ prompt: |
even across several files; any change to source code or tests, and any deep
code investigation, goes to a sub-agent.
You have exactly TWO sub-agents (see agents/<name>/):
You have exactly TWO sub-agents (see agents/<dir>/):
- `researcher` — a read-only repo explorer (claude-sdk). Ask it how something
actually works, what a diff changed, or to trace behavior across files; it
returns a findings report and edits nothing.
@@ -107,7 +107,7 @@ prompt: |
- api-docs — document a module or public API surface.
Skills are prose, so you author new ones yourself into Scribe's OWN skills
directory (`examples/scribe/skills/<name>/SKILL.md`), never the host
directory (`examples/scribe/skills/<dir>/SKILL.md`), never the host
`~/.claude/skills/` directory.
async: true
@@ -139,7 +139,7 @@ guardrails:
gate_pushes: false
tools:
# The two sub-agents — see agents/<name>/. `researcher` explores read-only on
# The two sub-agents — see agents/<dir>/. `researcher` explores read-only on
# claude-sdk; `reviewer` fact-checks on codex (a different vendor) so the
# cross-model check is meaningful.
agents:
+3 -3
View File
@@ -42,7 +42,7 @@ prompt: |
by the `read_only_os` policy: any `sys_os_write` / `sys_os_edit` is DENIED, so
write the fix into your report, not into the file.
You have exactly TWO sub-agents (see agents/<name>/):
You have exactly TWO sub-agents (see agents/<dir>/):
- `scanner` — a read-only repo explorer (claude-sdk). Ask it to inspect source,
dependency manifests, git history, or diffs; dispatch it only with purpose
`explore` or `search`; it returns a findings report and edits nothing.
@@ -115,7 +115,7 @@ prompt: |
findings report.
Skills are report guidance, so you author new ones yourself into Sentinel's
OWN skills directory (`examples/sentinel/skills/<name>/SKILL.md`), never the
OWN skills directory (`examples/sentinel/skills/<dir>/SKILL.md`), never the
host `~/.claude/skills/` directory.
async: true
@@ -177,7 +177,7 @@ guardrails:
allowed_purposes: [explore, search, review]
tools:
# The two sub-agents — see agents/<name>/. `scanner` explores read-only on
# The two sub-agents — see agents/<dir>/. `scanner` explores read-only on
# claude-sdk; `reviewer` fact-checks on codex (a different vendor) so the
# cross-vendor check is meaningful.
agents:
+4 -4
View File
@@ -293,11 +293,11 @@ This integration is a **separate package** (`omnigent-slack`) with heavy deps
(slack_bolt, aiohttp) kept out of the core `omnigent` install. It resolves as an
editable path dep of the root `omnigent` package via the `slack` extra (see
`[tool.uv.sources]` in the root `pyproject.toml`), and shares the root's dev
tooling (ruff, mypy, pytest) and config rather than carrying its own. Work on it
tooling (Ruff, Pyrefly, pytest) and config rather than carrying its own. Work on it
from the repo-root env:
```bash
# From the repo root — add the slack extra to your existing extras:
uv sync --extra slack # e.g. --extra all --extra dev --extra slack
uv run omni integration slack
# From the repo root — install the Slack capability and contributor tooling:
uv sync --extra slack --group dev
uv run --no-sync omni integration slack
```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnigent-slack"
version = "0.10.0.dev0"
version = "0.11.0.dev0"
description = "Slack Socket Mode bot that drives Omnigent sessions."
readme = "README.md"
requires-python = ">=3.12"
+6 -1
View File
@@ -180,7 +180,12 @@ def _register_error_handler(app: AsyncApp, logger: logging.Logger) -> None:
@app.error
async def _on_error(error: Exception, body: dict[str, Any]) -> None:
logger.exception("Unhandled Slack listener error; body_type=%s", body.get("type"))
logger.error(
"Unhandled Slack listener error: %s; body_type=%s",
error,
body.get("type"),
exc_info=(type(error), error, error.__traceback__),
)
def register_handlers(app: AsyncApp, service: SlackOmnigentService) -> None:
+30 -5
View File
@@ -20,12 +20,37 @@ async def test_error_handler_logs_with_traceback(caplog: pytest.LogCaptureFixtur
error_handler = app._async_middleware_error_handler
assert error_handler is not None
with caplog.at_level(logging.ERROR, logger="test-app-error"):
def raise_error() -> RuntimeError:
try:
raise RuntimeError("boom")
except RuntimeError as exc:
await error_handler.func(error=exc, body={"type": "event_callback"})
return exc
assert any("Unhandled Slack listener error" in r.message for r in caplog.records)
# The exception traceback is attached (logger.exception), not just the message.
assert any(r.exc_info for r in caplog.records)
error = raise_error()
with caplog.at_level(logging.ERROR, logger="test-app-error"):
await error_handler.func(error=error, body={"type": "event_callback"})
record = caplog.records[-1]
assert "Unhandled Slack listener error: boom" in record.message
assert record.exc_info == (RuntimeError, error, error.__traceback__)
@pytest.mark.asyncio
async def test_error_handler_logs_exception_without_active_traceback(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An exception passed outside an ``except`` block still logs usefully."""
app = AsyncApp(token="xoxb-dummy", signing_secret="x")
logger = logging.getLogger("test-app-error-no-traceback")
_register_error_handler(app, logger)
error_handler = app._async_middleware_error_handler
assert error_handler is not None
error = ValueError("created outside an except block")
with caplog.at_level(logging.ERROR, logger="test-app-error-no-traceback"):
await error_handler.func(error=error, body={"type": "event_callback"})
record = caplog.records[-1]
assert "created outside an except block" in record.message
assert record.exc_info == (ValueError, error, None)
assert "NoneType: None" not in caplog.text
+11 -1
View File
@@ -938,7 +938,7 @@ async def test_exhausted_reconnect_shows_non_alarming_text(tmp_path: Path) -> No
client=slack,
context={"bot_user_id": "B1"},
)
await _wait_for_posts(slack, 1)
await _wait_for_ack_deleted(slack)
await service.shutdown()
# The notice is a public post (not ephemeral): the non-alarming stream-drop
@@ -1719,6 +1719,16 @@ async def _wait_for_posts(client: FakeSlackClient, count: int) -> None:
raise AssertionError(f"Timed out waiting for {count} posts")
async def _wait_for_ack_deleted(client: FakeSlackClient) -> None:
# Wait until the "Working on it…" ack has been deleted and a follow-up post
# has landed — i.e. stop_with() fully completed (delete then postMessage).
for _ in range(50):
if client.deleted_ts and client.posts:
return
await asyncio.sleep(0.02)
raise AssertionError("Timed out waiting for ack deletion and follow-up post")
async def test_unreachable_server_prompts_config_command(tmp_path: Path) -> None:
store = await _store(tmp_path)
slack = FakeSlackClient()
+4 -4
View File
@@ -14,7 +14,7 @@ _check-uv:
uv run --no-sync pre-commit --version
_ensure-uv:
uv sync --extra all --extra dev
uv sync --extra all --group dev
# --- iOS Ruby dependencies ---
@@ -87,11 +87,11 @@ electron-build: _ensure-web _ensure-electron
[group('lint')]
lint: _ensure-uv
uv run pre-commit run
uv run --no-sync pre-commit run
[group('lint')]
lint-all: _ensure-uv
uv run pre-commit run --all-files
uv run --no-sync pre-commit run --all-files
[group('lint')]
typecheck-python: _ensure-uv
@@ -108,4 +108,4 @@ lint-ts:
[group('lint')]
normalize-locks: _ensure-uv
uv run scripts/normalize_uv_lock_registry.py uv.lock || true
uv run --no-sync scripts/normalize_uv_lock_registry.py uv.lock || true
+204 -65
View File
@@ -29,72 +29,211 @@ from omnigent._env_compat import mirror_legacy_env as _mirror_legacy_env # noqa
_mirror_legacy_env()
from omnigent.inner.datamodel import ( # noqa: E402 — must follow md5 patch
AgentDef,
Connection,
Credentials,
History,
Memory,
MemoryConfig,
Message,
ParamDef,
SessionState,
)
from omnigent.inner.executor import ( # noqa: E402 — must follow md5 patch
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
TextChunk,
ToolCallComplete,
ToolCallRequest,
TurnCancelled,
TurnComplete,
)
from omnigent.inner.policies import ( # noqa: E402 — must follow md5 patch
FunctionPolicy,
Policy,
PolicyAction,
PolicyResult,
PromptPolicy,
)
from omnigent.inner.tools import ( # noqa: E402 — must follow md5 patch
AgentTool,
CancellableFunctionTool,
FunctionTool,
HandoffTool,
InheritedTool,
MCPTool,
SkillTool,
Tool,
)
# The public names below re-export lazily (PEP 562). This package init is on
# the hot path of every ``python -m omnigent.<hook>`` subprocess Claude Code
# spawns — once per streamed text chunk (the TUI blocks on the MessageDisplay
# hook), per statusline refresh, and per tool call — and eagerly importing the
# datamodel/executor graph here cost those spawns ~250 ms each. Names resolve
# on first attribute access and are cached in module globals; the import-graph
# guards live in tests/test_claude_native_message_display_hook.py and the
# wall-clock trend in the ``native_hook_spawn`` benchmark journey.
import importlib # noqa: E402
from typing import TYPE_CHECKING, Any # noqa: E402
if TYPE_CHECKING:
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor as ClaudeSDKExecutor
from omnigent.inner.codex_executor import CodexExecutor as CodexExecutor
from omnigent.inner.databricks_executor import DatabricksExecutor as DatabricksExecutor
from omnigent.inner.datamodel import (
AgentDef as AgentDef,
)
from omnigent.inner.datamodel import (
Connection as Connection,
)
from omnigent.inner.datamodel import (
Credentials as Credentials,
)
from omnigent.inner.datamodel import (
History as History,
)
from omnigent.inner.datamodel import (
Memory as Memory,
)
from omnigent.inner.datamodel import (
MemoryConfig as MemoryConfig,
)
from omnigent.inner.datamodel import (
Message as Message,
)
from omnigent.inner.datamodel import (
ParamDef as ParamDef,
)
from omnigent.inner.datamodel import (
SessionState as SessionState,
)
from omnigent.inner.executor import (
Executor as Executor,
)
from omnigent.inner.executor import (
ExecutorConfig as ExecutorConfig,
)
from omnigent.inner.executor import (
ExecutorError as ExecutorError,
)
from omnigent.inner.executor import (
ExecutorEvent as ExecutorEvent,
)
from omnigent.inner.executor import (
TextChunk as TextChunk,
)
from omnigent.inner.executor import (
ToolCallComplete as ToolCallComplete,
)
from omnigent.inner.executor import (
ToolCallRequest as ToolCallRequest,
)
from omnigent.inner.executor import (
TurnCancelled as TurnCancelled,
)
from omnigent.inner.executor import (
TurnComplete as TurnComplete,
)
from omnigent.inner.loader import load_agent_def as load_agent_def
from omnigent.inner.open_responses_sdk import OpenResponsesExecutor as OpenResponsesExecutor
from omnigent.inner.openai_agents_sdk_executor import (
OpenAIAgentsSDKExecutor as OpenAIAgentsSDKExecutor,
)
from omnigent.inner.policies import (
FunctionPolicy as FunctionPolicy,
)
from omnigent.inner.policies import (
Policy as Policy,
)
from omnigent.inner.policies import (
PolicyAction as PolicyAction,
)
from omnigent.inner.policies import (
PolicyResult as PolicyResult,
)
from omnigent.inner.policies import (
PromptPolicy as PromptPolicy,
)
from omnigent.inner.tools import (
AgentTool as AgentTool,
)
from omnigent.inner.tools import (
CancellableFunctionTool as CancellableFunctionTool,
)
from omnigent.inner.tools import (
FunctionTool as FunctionTool,
)
from omnigent.inner.tools import (
HandoffTool as HandoffTool,
)
from omnigent.inner.tools import (
InheritedTool as InheritedTool,
)
from omnigent.inner.tools import (
MCPTool as MCPTool,
)
from omnigent.inner.tools import (
SkillTool as SkillTool,
)
from omnigent.inner.tools import (
Tool as Tool,
)
from omnigent.inner.tracing import (
disable_tracing as disable_tracing,
)
from omnigent.inner.tracing import (
enable_tracing as enable_tracing,
)
from omnigent.inner.tracing import (
is_tracing_enabled as is_tracing_enabled,
)
# Public name → defining module for the always-present re-exports.
_LAZY_EXPORTS = {
"AgentDef": "omnigent.inner.datamodel",
"Connection": "omnigent.inner.datamodel",
"Credentials": "omnigent.inner.datamodel",
"History": "omnigent.inner.datamodel",
"Memory": "omnigent.inner.datamodel",
"MemoryConfig": "omnigent.inner.datamodel",
"Message": "omnigent.inner.datamodel",
"ParamDef": "omnigent.inner.datamodel",
"SessionState": "omnigent.inner.datamodel",
"Executor": "omnigent.inner.executor",
"ExecutorConfig": "omnigent.inner.executor",
"ExecutorError": "omnigent.inner.executor",
"ExecutorEvent": "omnigent.inner.executor",
"TextChunk": "omnigent.inner.executor",
"ToolCallComplete": "omnigent.inner.executor",
"ToolCallRequest": "omnigent.inner.executor",
"TurnCancelled": "omnigent.inner.executor",
"TurnComplete": "omnigent.inner.executor",
"FunctionPolicy": "omnigent.inner.policies",
"Policy": "omnigent.inner.policies",
"PolicyAction": "omnigent.inner.policies",
"PolicyResult": "omnigent.inner.policies",
"PromptPolicy": "omnigent.inner.policies",
"AgentTool": "omnigent.inner.tools",
"CancellableFunctionTool": "omnigent.inner.tools",
"FunctionTool": "omnigent.inner.tools",
"HandoffTool": "omnigent.inner.tools",
"InheritedTool": "omnigent.inner.tools",
"MCPTool": "omnigent.inner.tools",
"SkillTool": "omnigent.inner.tools",
"Tool": "omnigent.inner.tools",
"load_agent_def": "omnigent.inner.loader",
"disable_tracing": "omnigent.inner.tracing",
"enable_tracing": "omnigent.inner.tracing",
"is_tracing_enabled": "omnigent.inner.tracing",
}
# Optional executors resolve to ``None`` when their extra's dependencies are
# absent, matching the former eager try/except imports. Databricks also
# tolerates ``OSError``: its SDK can raise one probing credentials at import.
_OPTIONAL_EXPORTS = {
"DatabricksExecutor": ("omnigent.inner.databricks_executor", (OSError, ImportError)),
"ClaudeSDKExecutor": ("omnigent.inner.claude_sdk_executor", (ImportError,)),
"OpenResponsesExecutor": ("omnigent.inner.open_responses_sdk", (ImportError,)),
"OpenAIAgentsSDKExecutor": ("omnigent.inner.openai_agents_sdk_executor", (ImportError,)),
"CodexExecutor": ("omnigent.inner.codex_executor", (ImportError,)),
}
def __getattr__(name: str) -> Any:
"""Resolve a lazy re-export (or submodule) on first attribute access."""
target = _LAZY_EXPORTS.get(name)
if target is not None:
value = getattr(importlib.import_module(target), name)
globals()[name] = value
return value
optional = _OPTIONAL_EXPORTS.get(name)
if optional is not None:
target, absent_exceptions = optional
try:
value = getattr(importlib.import_module(target), name)
except absent_exceptions:
value = None
globals()[name] = value
return value
# The eager imports used to bind ``inner`` (and other submodules touched
# by them) as package attributes; keep ``omnigent.<submodule>`` access
# working for consumers that only ran ``import omnigent``.
try:
return importlib.import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
if exc.name != f"{__name__}.{name}":
raise
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
def __dir__() -> list[str]:
"""Include the lazy re-exports in ``dir(omnigent)``."""
return sorted(set(globals()) | set(__all__))
try:
from omnigent.inner.databricks_executor import DatabricksExecutor
except (OSError, ImportError):
DatabricksExecutor = None # type: ignore[misc,assignment]
try:
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
except ImportError:
ClaudeSDKExecutor = None # type: ignore[misc,assignment]
try:
from omnigent.inner.open_responses_sdk import OpenResponsesExecutor
except ImportError:
OpenResponsesExecutor = None # type: ignore[misc,assignment]
try:
from omnigent.inner.openai_agents_sdk_executor import OpenAIAgentsSDKExecutor
except ImportError:
OpenAIAgentsSDKExecutor = None # type: ignore[misc,assignment]
try:
from omnigent.inner.codex_executor import CodexExecutor
except ImportError:
CodexExecutor = None # type: ignore[misc,assignment]
from omnigent.inner.loader import load_agent_def # noqa: E402 — must follow md5 patch
from omnigent.inner.tracing import ( # noqa: E402 — must follow md5 patch
disable_tracing,
enable_tracing,
is_tracing_enabled,
)
__all__ = [
"AgentDef",
+13
View File
@@ -49,6 +49,19 @@ def record_post_failure(event_type: str, error: BaseException) -> None:
_state["last_post_failure"] = (time.monotonic(), f"{event_type}: {error!r}")
def record_transport_failure(detail: str) -> None:
"""Record an already-formatted transport failure into the shared slot.
The SDK codex head has no forwarder POST path, but its subprocess still
runs under the same idle-turn watchdog. When the model gateway rejects the
CLI (e.g. a 401 read off the CLI's stderr), the turn emits no events and the
watchdog would otherwise blame a generic "wedged LLM". Recording the parsed
cause here lets the watchdog attribute the real failure, exactly as the
native forwarder path does. *detail* is a ready-to-surface human string.
"""
_state["last_post_failure"] = (time.monotonic(), detail)
def note_post_success() -> None:
"""
Clear the failure record after a POST that reached the server.
+8
View File
@@ -146,6 +146,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
- Connection-establishment / pool-acquire failures
(:data:`_DELIVERY_SAFE_RETRY_ERRORS`): no bytes were sent not
delivered safe to retry, so ``False``.
- An unbound ``RequestError``: the failure occurred before httpx
associated the exception with the outbound request (for example,
an auth flow failed before yielding it). No bytes were sent safe
to retry, so ``False``.
- Any other transport error (read/write timeout, read/write error,
remote protocol error): the request was sent and we never saw a
response, so the server may have processed it ambiguous
@@ -159,6 +163,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
return False
if isinstance(exc, _DELIVERY_SAFE_RETRY_ERRORS):
return False
try:
_ = exc.request
except RuntimeError:
return False
return True
+26
View File
@@ -24,6 +24,14 @@ spawn-env builder. Rows own their auth and model selection (``OWN_AUTH``): no
Omnigent credential or model override is wired, so a ``/model`` pick is
rejected up front rather than silently dropped.
One consequence worth knowing before adding a row: the generic ACP spawn env is
deny-by-default and a row has no ``env_passthrough`` of its own (only a
user-configured ``acp:<slug>`` agent can declare one), so a row's CLI reaches the
agent with the base environment only. A vendor that configures or authenticates
*solely* from an environment variable therefore needs a user-configured agent
rather than a row here; a vendor that reads stored credentials from disk (Devin,
Grok's OAuth login) works as a row.
This module stays import-light (stdlib + :mod:`omnigent.harness_install_spec`)
so the registry, onboarding, and runner layers can all read it without cycles.
"""
@@ -73,6 +81,24 @@ class AcpCliHarness:
# Keyed by canonical harness id. Keep keys sorted; each row's registrations
# derive from here (see the module docstring for the full list).
ACP_CLI_HARNESSES: dict[str, AcpCliHarness] = {
# Devin (Cognition's ``devin`` CLI) drives ``devin acp`` — its ACP stdio
# server. Ships via a curl installer (not npm) and authenticates through its
# own ``devin auth login``, which writes a credential file it reads back at
# spawn; Omnigent stores nothing. The row runs Devin's account-default model:
# a row carries no per-user model, and ``DEVIN_MODEL`` cannot reach the agent
# (see the env note above), so pinning a model needs a user-configured
# ``acp:<slug>`` agent whose command passes ``--model``.
"devin": AcpCliHarness(
install=HarnessInstallSpec(
"Devin",
"devin",
None,
login_args=("auth", "login"),
install_hint="curl -fsSL https://cli.devin.ai/install.sh | bash",
auth_hint="run `devin auth login` (Omnigent stores no Devin credential)",
),
args=("acp",),
),
# Grok Build (xAI's ``grok`` CLI) drives ``grok agent stdio``. Ships via a
# curl installer (not npm) and authenticates through its own ``grok login``
# (xAI OAuth, device-code capable) or ``XAI_API_KEY``; Omnigent stores no
+26 -24
View File
@@ -75,7 +75,6 @@ from tempfile import TemporaryDirectory
import click
import httpx
import yaml
from omnigent_client._http import is_loopback_url
from omnigent._native_resume_hint import echo_native_resume_hint
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
@@ -131,6 +130,7 @@ from omnigent.entities.session_resources import terminal_resource_id
from omnigent.host.daemon_launch import (
error_text,
launch_or_reuse_daemon_runner,
open_daemon_client,
wait_for_host_online,
wait_for_runner_online,
)
@@ -479,7 +479,11 @@ def _run_with_remote_server(
from omnigent.cli import _ensure_host_daemon
from omnigent.host.identity import load_or_create_host_identity
headers = _remote_headers(server_url=base_url)
# This machine's host id keys the WebSocket attach handshake (and its
# reconnects) to the replica holding the runner's tunnel; the CLI can set WS
# headers, so it rides the header (emitted only on a host-sharded deployment).
host_id = load_or_create_host_identity().host_id
headers = _remote_headers(server_url=base_url, host_id=host_id)
try:
resolved_session_id = _resolve_session_id_for_resume(
base_url=base_url,
@@ -499,7 +503,6 @@ def _run_with_remote_server(
with runner_startup_progress(initial_message="Preparing Antigravity...") as progress:
progress.update("Connecting to local daemon...")
_ensure_host_daemon(base_url)
host_id = load_or_create_host_identity().host_id
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
prepared = await _prepare_antigravity_terminal_via_daemon(
base_url=base_url,
@@ -529,7 +532,7 @@ def _run_with_remote_server(
:returns: None.
"""
new_headers = _remote_headers(server_url=base_url)
new_headers = _remote_headers(server_url=base_url, host_id=host_id)
headers.clear()
headers.update(new_headers)
@@ -590,12 +593,9 @@ async def _prepare_antigravity_terminal(
:raises click.ClickException: If any server operation fails.
"""
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
timeout=timeout,
trust_env=not is_loopback_url(base_url),
) as client:
from omnigent.cli_auth import open_server_client
async with open_server_client(base_url, headers=headers, timeout=timeout) as client:
bridge_id: str
conversation_id: str
resume = False
@@ -787,15 +787,11 @@ async def _prepare_antigravity_terminal_via_daemon(
:raises click.ClickException: If setup fails.
"""
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
timeout=timeout,
trust_env=not is_loopback_url(base_url),
) as client:
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
bridge_id: str
conversation_id: str
resume = False
fresh_session = session_id is None
if session_id is None:
if session_bundle is None:
raise click.ClickException(
@@ -804,10 +800,13 @@ async def _prepare_antigravity_terminal_via_daemon(
_update_progress(startup_progress, "Creating Antigravity session...")
bridge_id = _mint_agy_conversation_id()
conversation_id = bridge_id
session_id = await _create_antigravity_session(
client,
session_bundle,
bridge_id=bridge_id,
session_id, _ = await asyncio.gather(
_create_antigravity_session(
client,
session_bundle,
bridge_id=bridge_id,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_progress(startup_progress, "Loading Antigravity session...")
@@ -851,13 +850,15 @@ async def _prepare_antigravity_terminal_via_daemon(
conversation_id = external if isinstance(external, str) and external else bridge_id
resume = isinstance(external, str) and bool(external)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
host_id=host_id,
session_id=session_id,
workspace=workspace,
fresh=fresh_session,
)
_update_progress(startup_progress, "Waiting for runner...")
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
@@ -1620,10 +1621,11 @@ async def _close_antigravity_terminal(
:param terminal_id: Terminal resource id.
:returns: None.
"""
from omnigent.cli_auth import open_server_client
try:
async with httpx.AsyncClient(
base_url=base_url,
trust_env=not is_loopback_url(base_url),
async with open_server_client(
base_url,
headers=headers,
timeout=httpx.Timeout(10.0),
) as client:
+4 -2
View File
@@ -3269,8 +3269,10 @@ async def run_reader_with_bridge(
# would keep targeting the rotated-away session).
current = {"session_id": session_id}
async with httpx.AsyncClient(
base_url=base_url,
from omnigent.cli_auth import open_server_client
async with open_server_client(
base_url,
headers=headers,
auth=auth,
timeout=httpx.Timeout(_READER_CLIENT_TIMEOUT_SECONDS),
+185 -47
View File
@@ -603,9 +603,51 @@ def _is_url(target: str) -> bool:
# Server URL client helpers
# ---------------------------------------------------------------------------
# Client-side ``session_id → host_id`` tracking, for routing a session's
# tunnel-bound traffic to the right server replica when the server runs
# multiple.
#
# A host's control tunnel and its runners' tunnels register on a single
# replica, so the turn / resource / stream calls for a session running on that
# host must name the host or they can reach a different replica than the runner
# tunnel they need. The host_id is the routing key; it's populated when the CLI
# learns a session's host (session GETs, daemon launch) and read once when a
# client for that session is built — callers pass the session_id they already
# have to ``_server_auth`` rather than the auth re-deriving it per request. This
# is the Python peer of the web UI's ``sessionHost.ts``. (The host_id is
# translated into the routing header inside ``cli_auth.databricks_request_headers``;
# OSS only ever threads a host_id.)
_session_hosts: dict[str, str] = {}
def set_session_host(session_id: str, host_id: str | None) -> None:
"""Record (or clear) the host a session is bound to.
A ``None``/empty host clears any stale mapping so a session that loses its
host binding stops routing to the old replica.
:param session_id: Session id, e.g. ``"conv_abc123"``.
:param host_id: The bound host id, e.g. ``"host_abc123"``, or ``None``.
"""
if host_id:
_session_hosts[session_id] = host_id
else:
_session_hosts.pop(session_id, None)
def get_session_host(session_id: str) -> str | None:
"""Return a session's bound host id, or ``None`` when unknown.
:param session_id: Session id, e.g. ``"conv_abc123"``.
:returns: The host id, or ``None`` (session not seen yet, or hostless).
"""
return _session_hosts.get(session_id)
def _remote_headers(
server_url: str | None = None,
*,
host_id: str | None,
) -> dict[str, str]:
"""
Build headers for remote AP-server requests.
@@ -626,6 +668,11 @@ def _remote_headers(
:param server_url: Optional remote server URL for looking up
stored OIDC tokens, e.g. ``"http://localhost:6767"``.
:param host_id: The host a request is scoped to, or ``None`` when the
caller has no host to name (a host-less read, or a runner-internal
request that keys off the runner-env host_id). Required-keyword with
no default so every call site consciously decides pass the request
path's host when it has one rather than silently defaulting to unkeyed.
:returns: Headers to pass to httpx / OmnigentClient.
"""
# Resolve the bearer in the documented precedence order (one credential
@@ -654,14 +701,25 @@ def _remote_headers(
headers["Authorization"] = f"Bearer {creds.token}"
# Workspace routing: when a ?o= selector was recorded at login, name the
# workspace or the request routes to the account. Merged onto the result
# because these ad-hoc requests carry no httpx Auth.
# because these ad-hoc requests carry no httpx Auth. ``host_id`` pins a
# host-scoped request to the server replica holding that host's tunnel
# (translated to the routing header inside the builder); callers derive it
# from the request path.
if server_url:
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(server_url))
headers.update(databricks_request_headers(server_url, host_id=host_id))
return headers
# Cache the resolved _DatabricksBearerAuth object per server URL so that
# repeated calls to _remote_headers for the same URL reuse the same SDK
# Config instance. The SDK's Config.authenticate() caches the OAuth token
# in memory and only re-runs the CLI shell-out when it nears expiry, so
# reusing the object is both fast and correct for long-running callers.
_databricks_auth_cache: dict[str, object] = {}
def _stored_databricks_record_token(server_url: str) -> str | None:
"""Mint a workspace token from a stored Databricks Apps record.
@@ -671,6 +729,12 @@ def _stored_databricks_record_token(server_url: str) -> str | None:
that issue many requests should use :class:`_DatabricksTokenAuth`,
which reuses the SDK config across requests.
The resolved ``_DatabricksBearerAuth`` object is cached per
``server_url`` so repeated calls reuse the same SDK ``Config``
instance. The SDK serves the cached OAuth token from memory and only
re-runs the Databricks CLI when the token nears expiry, so this is
both fast on repeat calls and safe for long-running callers.
:param server_url: The remote server URL, e.g.
``"https://myapp-123.aws.databricksapps.com"``.
:returns: A bearer token, or ``None`` when no pointer record is
@@ -686,8 +750,11 @@ def _stored_databricks_record_token(server_url: str) -> str | None:
if workspace_host is None:
return None
try:
auth, _host = _resolve_databricks_auth(host=workspace_host)
return auth.current_token()
auth = _databricks_auth_cache.get(server_url)
if auth is None:
auth, _host = _resolve_databricks_auth(host=workspace_host)
_databricks_auth_cache[server_url] = auth
return auth.current_token() # type: ignore[union-attr]
except (DatabricksAuthError, ImportError, ValueError):
return None
@@ -708,12 +775,18 @@ class _DatabricksTokenAuth(httpx.Auth):
def __init__(
self,
server_url: str | None = None,
*,
session_id: str | None = None,
) -> None:
"""
:param server_url: Remote server URL for looking up stored
OIDC tokens, e.g. ``"http://localhost:6767"``.
:param session_id: The single session this client drives; its
requests are pinned to that session's host replica. ``None``
for a hostless / local client.
"""
self._server_url = server_url
self._session_id = session_id
raw = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
self._static_token = raw.strip() if raw else None
# Lazily-resolved, then reused, SDK auth (one Config → one token
@@ -723,6 +796,19 @@ class _DatabricksTokenAuth(httpx.Auth):
self._sdk_auth: _DatabricksBearerAuth | None = None
self._sdk_auth_resolved = False
def pin_session(self, session_id: str | None) -> None:
"""Repoint this auth at a different session's host.
``auth_flow`` reads ``get_session_host(self._session_id)`` per request,
so changing the pinned session id changes which host replica the slice
key routes to without rebuilding the client. Used when a client
outlives the session it was built for (e.g. a ``--fork`` in the REPL
resumes under a new conversation id on a new host).
:param session_id: The session id to pin to, or ``None`` to unpin.
"""
self._session_id = session_id
def _sdk_token(self) -> str | None:
"""
Return a bearer token from the reused SDK auth, or ``None``.
@@ -777,27 +863,34 @@ class _DatabricksTokenAuth(httpx.Auth):
:yields: The request with auth header set.
"""
# Workspace routing (empty when none recorded); independent of the
# credential branch below.
# credential branch below. On a host-sharded deployment, also pin the
# turn/resource/stream traffic for this client's session to the replica
# holding its runner tunnel — the slice key is the session's host_id,
# from the session→host map. An unsharded server has no sharding layer,
# so no key.
if self._server_url:
from omnigent.cli_auth import databricks_request_headers
request.headers.update(databricks_request_headers(self._server_url))
session_host = get_session_host(self._session_id) if self._session_id else None
request.headers.update(
databricks_request_headers(self._server_url, host_id=session_host)
)
if self._static_token:
request.headers["Authorization"] = f"Bearer {self._static_token}"
yield request
return
# Check stored OIDC token from `omnigent login`.
if self._server_url:
from omnigent.cli_auth import load_token
else:
# Check stored OIDC token from `omnigent login`, then fall back to
# the reused Databricks SDK auth.
oidc_token = None
if self._server_url:
from omnigent.cli_auth import load_token
oidc_token = load_token(self._server_url)
oidc_token = load_token(self._server_url)
if oidc_token:
request.headers["Authorization"] = f"Bearer {oidc_token}"
yield request
return
token = self._sdk_token()
if token:
request.headers["Authorization"] = f"Bearer {token}"
else:
token = self._sdk_token()
if token:
request.headers["Authorization"] = f"Bearer {token}"
yield request
@@ -825,6 +918,8 @@ def _server_headers(
def _server_auth(
server_url: str | None = None,
*,
session_id: str | None,
) -> httpx.Auth | None:
"""
Build an httpx Auth for a remote Omnigent server client.
@@ -837,21 +932,29 @@ def _server_auth(
:param server_url: Optional remote server URL for looking up
stored OIDC tokens.
:param session_id: The single session this client drives, e.g.
``"conv_abc123"``. When set, the auth pins every request to the
replica holding that session's runner tunnel (its host, looked up
in the sessionhost map). Required-keyword with no default so every
caller consciously decides: pass the session when known so its
traffic co-locates, or ``None`` before a session exists / for a
host-less client (the slice key then falls back to the runner-env
or CLI-own-host id inside ``databricks_request_headers``).
:returns: Auth instance, or ``None``.
"""
raw = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
if raw and raw.strip():
return _DatabricksTokenAuth(server_url=server_url)
return _DatabricksTokenAuth(server_url=server_url, session_id=session_id)
# Check stored `omnigent login` records: a session JWT or a
# Databricks Apps pointer record.
if server_url:
from omnigent.cli_auth import load_databricks_workspace_host, load_token
if load_token(server_url) or load_databricks_workspace_host(server_url):
return _DatabricksTokenAuth(server_url=server_url)
return _DatabricksTokenAuth(server_url=server_url, session_id=session_id)
creds = _read_databrickscfg(None)
if creds is not None and creds.token:
return _DatabricksTokenAuth(server_url=server_url)
return _DatabricksTokenAuth(server_url=server_url, session_id=session_id)
return None
@@ -1129,7 +1232,7 @@ def _wrapper_label_for_conversation(
try:
resp = httpx.get(
f"{base_url}/v1/sessions/{conversation_id}",
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
timeout=10.0,
)
except httpx.HTTPError as exc:
@@ -1218,7 +1321,7 @@ def _attach_session_info(
try:
resp = httpx.get(
f"{base_url}/v1/sessions/{conversation_id}",
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
timeout=10.0,
)
except httpx.HTTPError as exc:
@@ -1232,6 +1335,15 @@ def _attach_session_info(
return empty
if not isinstance(body, dict):
return empty
# Record the session's host so host-scoped requests (turn dispatch,
# resource, stream) can reach the replica holding that host's runner tunnel.
# Always write — clearing a stale mapping when the server now reports no
# host (e.g. the session's runner was torn down) is as important as setting
# one, so later requests for a hostless session don't keep a dead slice key.
session_host = body.get("host_id")
set_session_host(
conversation_id, session_host if isinstance(session_host, str) and session_host else None
)
runner_id = body.get("runner_id")
snapshot_online = body.get("runner_online")
if not isinstance(runner_id, str) or not runner_id:
@@ -1269,7 +1381,7 @@ def _pick_agent(base_url: str, *, quiet: bool = False) -> str:
"""
resp = httpx.get(
f"{base_url}/v1/sessions",
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
params={"limit": 100},
timeout=10.0,
)
@@ -1477,24 +1589,29 @@ async def _prepare_chat_session_via_daemon(
)
from omnigent.host.daemon_launch import (
launch_or_reuse_daemon_runner,
open_daemon_client,
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_terminal import bind_session_runner
try:
async def resolve_session() -> tuple[str, bool]:
"""Fork, resume, or create the session to bind, and say if it is fresh.
:returns: The session id and whether it was created just now.
:raises click.ClickException: If the server rejects the create/fork.
"""
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
try:
if fork_session_id is not None:
fork_result = await sdk.sessions.fork(fork_session_id)
session_id = fork_result["id"]
elif resume_conversation_id is not None:
session_id = resume_conversation_id
else:
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
session_id = created.id
return fork_result["id"], False
if resume_conversation_id is not None:
return resume_conversation_id, False
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
return created.id, True
except ClientOmnigentError as exc:
# Any create/fork/resume rejection here is a server-side answer, not
# a client bug worth a traceback: a wrong base URL that answers
@@ -1505,25 +1622,32 @@ async def _prepare_chat_session_via_daemon(
f"Could not start a session on {base_url}: {exc}"
) from exc
try:
# A separate raw httpx client for the host-runner protocol (the daemon
# launch helpers operate on httpx, not the SDK).
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
auth=auth,
timeout=timeout,
trust_env=not is_loopback_url(base_url),
async with open_daemon_client(
base_url, headers, host_id, auth=auth, timeout=timeout
) as client:
if progress is not None:
progress.update(STARTUP_PHASE_CONNECTING)
await wait_for_host_online(
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
(session_id, fresh_session), _ = await asyncio.gather(
resolve_session(),
wait_for_host_online(
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
),
)
if progress is not None:
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
# Record the session's host so its turn/resource/stream traffic reaches
# the replica holding the host's runner tunnel.
set_session_host(session_id, host_id)
runner_id = await launch_or_reuse_daemon_runner(
client, host_id=host_id, session_id=session_id, workspace=workspace
client,
host_id=host_id,
session_id=session_id,
workspace=workspace,
fresh=fresh_session,
)
await wait_for_runner_online(
client, runner_id, timeout_s=_DAEMON_CHAT_RUNNER_ONLINE_TIMEOUT_S
@@ -1627,8 +1751,8 @@ def _chat_via_daemon(
# the spinner before printing its interactive prompt.
_await_accounts_first_run_setup(base_url, progress=progress)
headers = _remote_headers(server_url=base_url)
auth = _server_auth(server_url=base_url)
headers = _remote_headers(server_url=base_url, host_id=None)
auth = _server_auth(server_url=base_url, session_id=None)
host_id = load_or_create_host_identity().host_id
workspace = str(Path.cwd().resolve())
@@ -2077,7 +2201,7 @@ def _run_headless_prompt(
async with OmnigentClient(
base_url=base_url,
headers=_server_headers(runner_id=runner_id),
auth=_server_auth(server_url=base_url),
auth=_server_auth(server_url=base_url, session_id=None),
) as client:
# Both a local bundle and a remote registered agent go through
# the sessions API; _query_sessions_once picks the create route
@@ -3838,10 +3962,13 @@ def _run_repl(
if attach_harness is not None:
launch_harness = attach_harness
# Named so a --fork below can repoint it: the auth pins the slice key
# to whichever session it names, and a fork lands under a new id.
server_auth = _server_auth(server_url=base_url, session_id=resume_conversation_id)
async with OmnigentClient(
base_url=base_url,
headers=_server_headers(runner_id=runner_id),
auth=_server_auth(server_url=base_url),
auth=server_auth,
) as client:
# When --fork is set, call the fork endpoint before
# entering the REPL so the user lands in the fork.
@@ -3852,6 +3979,17 @@ def _run_repl(
except Exception as exc:
raise click.ClickException(f"Fork failed: {exc}") from exc
effective_resume_id = fork_result["id"]
# The fork is a fresh session on (possibly) a different host.
# Record its host and repoint the auth from the source session
# to the fork, so this client's requests route to the fork's
# replica instead of the source's for the rest of the REPL.
fork_host = fork_result.get("host_id")
set_session_host(
effective_resume_id,
fork_host if isinstance(fork_host, str) and fork_host else None,
)
if isinstance(server_auth, _DatabricksTokenAuth):
server_auth.pin_session(effective_resume_id)
click.echo(
f"Conversation forked. To return to the previous "
f"conversation, run --resume {fork_session_id}",
@@ -3932,7 +4070,7 @@ def _run_one_shot(
async with OmnigentClient(
base_url=base_url,
headers=_server_headers(runner_id=runner_id),
auth=_server_auth(server_url=base_url),
auth=_server_auth(server_url=base_url, session_id=resume_conversation_id),
) as client:
# Both a local bundle and a remote registered agent go through
# the sessions API; _query_sessions_once picks the create route
+33
View File
@@ -55,6 +55,21 @@ CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
#: takes — so it is not part of the vocabulary below.
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
#: BACK-COMPAT. Omnigent's picker-row id for the custom slot. Named for the
#: model the slot first carried (Sonnet 5, which had no family alias of its
#: own), but a Smart Routing launch pins ITS model there, so the id does not
#: describe the contents — read the slot, never this name. Sessions persist
#: it as a model override, so retiring it needs a migration; slated for
#: removal in 0.10.0 along with the row, once the ``sonnet`` pin is Sonnet 5.
LEGACY_CUSTOM_SLOT_ROW_ID = "sonnet_5"
#: BACK-COMPAT. Spellings the pre-0.10 substring test read as "this is the
#: custom slot's model", kept because :func:`normalized_model_id` does not
#: fold a vendor-prefixed ``anthropic/claude-sonnet-5`` onto the catalog id
#: the slot holds. Consulted only after an exact match misses; retired with
#: :data:`LEGACY_CUSTOM_SLOT_ROW_ID` in 0.10.0.
LEGACY_CUSTOM_SLOT_SPELLINGS: tuple[str, ...] = ("sonnet-5", "sonnet_5")
#: Launch-env keys that define this session's model vocabulary.
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
*ALIAS_MODEL_ENV_VARS.values(),
@@ -162,6 +177,14 @@ def claude_model_alias(
candidate = model.strip().lower()
if candidate in CLAUDE_MODEL_ALIASES:
return candidate
# Bracket variants of the family aliases (``sonnet[1m]``) are settable
# aliases in their own right — the harness enumerates them in /model's
# usage line and resolves the marker itself (the family pin plus the
# marker on a pinned env). Stepping one down to its family would
# silently drop the marker; refusing it blocks a switch the pane accepts.
base, bracket, marker = candidate.partition("[")
if bracket and marker.endswith("]") and base in CLAUDE_MODEL_ALIASES:
return candidate
pins = alias_pins(env)
normalized = normalized_model_id(model)
for alias, pinned in pins.items():
@@ -202,4 +225,14 @@ def claude_model_command_arg(
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
if custom and normalized_model_id(custom) == normalized_model_id(model):
return custom
candidate = model.strip()
if not alias_pins(env) and candidate.lower().startswith("claude-"):
# A full Anthropic model id names an EXACT generation, and ``/model``
# on an unpinned (canonical-endpoint) session accepts full ids
# verbatim — the same spelling the harness's own enumeration
# resolves. Stepping down to the family alias here would switch to
# claude's CURRENT generation of that family instead (picking
# "Opus 4.8 (1M context)" used to type ``/model opus`` and land on
# Opus 5).
return candidate
return claude_model_alias(model, env)
+883 -210
View File
File diff suppressed because it is too large Load Diff
+591 -52
View File
@@ -55,23 +55,22 @@ from urllib import error, request
from omnigent._platform import stable_user_id
from omnigent.claude_model_vocabulary import MODEL_VOCABULARY_ENV_VARS
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
from omnigent.claude_native_status import CONTEXT_RAW_FILE
from omnigent.json_types import JsonObject as _JsonObject
from omnigent.kiro_native_bridge import bridge_root as kiro_bridge_root
if TYPE_CHECKING:
import httpx
from omnigent.inner.datamodel import OSEnvSandboxSpec
from omnigent.inner.os_env import OSEnvironment
from omnigent.llms.context_window import ModelPricing
from omnigent.inner.bundle_skills import claude_native_skill_args
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.hook_scripts.subagent_router import (
AGENT_TOOL_MATCHER as CLAUDE_SUBAGENT_TOOL_MATCHER,
)
from omnigent.inner.os_env import OSEnvironment, create_os_environment
from omnigent.reasoning_effort import CLAUDE_EFFORTS
from omnigent.tools.base import Tool, ToolContext
from omnigent.tools.builtins.os_env import build_os_env_tools
_logger = logging.getLogger(__name__)
@@ -96,6 +95,10 @@ _RECENT_LOCAL_COMMAND_LINE_LIMIT = 200
_RECENT_LOCAL_COMMAND_WINDOW_S = 10.0
_FORKED_FROM_LINE_LIMIT = 200
_TOOL_RELAY_FILE = "tool_relay.json"
# Shell-sourceable sibling of tool_relay.json so the curl-based hook
# commands can discover the live relay without a JSON parser. Re-written
# on every relay start, so hooks survive runner restarts (new port).
_TOOL_RELAY_ENV_FILE = "tool_relay.env"
_TMUX_FILE = "tmux.json"
_PERMISSION_HOOK_FILE = "permission_hook.json"
_CONTEXT_FILE = "context.json"
@@ -176,11 +179,58 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
# whether the draft is rendered in the input box. Short enough to fit
# on the prompt row of a default 80-column detached pane.
_DRAFT_NEEDLE_MAX_CHARS = 24
# Mode footer Claude Code renders below its input box, keyed by
# ``--permission-mode`` value. There is no non-interactive mode command, so
# a live switch cycles with shift+tab and reads this footer to know where it
# landed. The prompting mode is ``default`` on the CLI, "manual" on screen.
_PERMISSION_MODE_FOOTERS: dict[str, str] = {
"default": "manual mode on",
"acceptEdits": "accept edits on",
"plan": "plan mode on",
"auto": "auto mode on",
}
# Modes shift+tab can reach. ``dontAsk`` is never in the cycle and
# ``bypassPermissions`` only joins it when launched into, so both are
# rejected up front.
CYCLEABLE_PERMISSION_MODES = frozenset(_PERMISSION_MODE_FOOTERS)
# Cap on shift+tab presses. The cycle is 3-5 modes wide depending on which
# optional modes are enabled, so a full lap plus slack proves the target is
# unreachable rather than slow.
_MODE_CYCLE_MAX_PRESSES = 8
# Wait for the footer to repaint after a shift+tab before reading it.
_MODE_FOOTER_SETTLE_TIMEOUT_S = 2.0
_MODE_FOOTER_POLL_INTERVAL_S = 0.1
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
# picker the person opened by hand covers the input box, so an injection would
# be lost; the readiness gate treats it as "not ready".
_MODEL_PICKER_OPEN_HINT = "use this session only"
# Header of the ctrl+r prompt-history search. Like the picker it covers the
# input box, but its selected history row renders the composer's ```` glyph
# above the filter box's frame rule, so the readiness scan alone reads it as
# a mounted input box — keystrokes would land in the filter field, and the
# submit Enter would replay whatever old prompt is selected.
_REVERSE_SEARCH_OPEN_HINT = "Search prompts ·"
# Surfaces a person can leave covering the composer from the embedded
# terminal. Each documents Escape as its dismissal ("Esc to cancel"), which
# closes it without committing anything and restores the empty input box, so
# an injected web-UI message reclaims the pane instead of typing into the
# surface. Shell mode (``!``) also occupies the composer but has no safe
# textual marker: its footer line ("! for shell mode") appears verbatim in
# the ``?`` shortcuts panel while the composer is fully usable.
_OCCUPIED_INPUT_HINTS: tuple[str, ...] = (
_REVERSE_SEARCH_OPEN_HINT,
_MODEL_PICKER_OPEN_HINT,
)
# How long to keep dismissing an occupying surface that verifiably stays on
# screen, and the spacing between repeated Escapes — a busy repaint can
# swallow one (same reasoning as ``_SUBMIT_RETRY_INTERVAL_S``). The spacing
# also bounds a residual hazard: were a successful Escape's repaint to
# outlast it, the stale hint would draw a retry onto the bare composer
# (interrupting a turn). 0.75s dwarfs a TUI repaint, so that window is
# accepted rather than confirmation-gated.
_OCCUPIED_INPUT_DISMISS_TIMEOUT_S = 3.0
_OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S = 0.75
# Titles of the confirmation dialog Claude Code pops when a switch invalidates
# the prompt cache — one component, titled for what is being switched. It only
# appears on a session with history, and it took ~1.9s to render on a warm
@@ -224,6 +274,14 @@ _INVOCATION_SETTINGS_FILE = "claude-settings.json"
ToolExecutor = Callable[[str, _JsonObject], Awaitable[object]]
class ClaudePromptTimeout(RuntimeError):
"""Claude Code's input box did not render before delivery timed out."""
class TmuxSessionNotAdvertised(RuntimeError):
"""The bridge's tmux target was not advertised before the deadline."""
def _absolute_syntactic_path(path: Path) -> Path:
"""
Return an absolute path without following symlinks.
@@ -417,6 +475,11 @@ class TranscriptReadResult:
entry was scanned.
:param latest_model: ``message.model`` from the most recent
assistant entry, or ``None``.
:param latest_custom_title: ``customTitle`` from the most recent
``custom-title`` record the explicit title a ``/rename`` typed
in the Claude Code pane writes. ``None`` when no such record was
scanned. Claude's own auto-generated ``aiTitle`` is deliberately
not surfaced here; Omnigent titles unnamed sessions itself.
"""
line_cursor: int
@@ -425,6 +488,7 @@ class TranscriptReadResult:
items: list[ClaudeTranscriptItem]
latest_usage: dict[str, int] | None = None
latest_model: str | None = None
latest_custom_title: str | None = None
@dataclass(frozen=True)
@@ -1184,6 +1248,48 @@ def read_model_env(bridge_dir: Path) -> dict[str, str]:
}
def record_model_vocabulary(
bridge_dir: Path,
*,
launch_env: Mapping[str, str] | None,
launch_model: str | None,
) -> None:
"""
Persist the launch's model vocabulary after the bridge dir exists.
The runner prepares the bridge before it resolves the provider config,
so the vocabulary (alias pins + custom slot) and the launch model land
here in a second write once known the same keys
:func:`prepare_bridge_dir` records on the CLI path, so
:func:`read_model_env` / :func:`read_launch_model` serve both paths
identically.
:param bridge_dir: Bridge directory path.
:param launch_env: The resolved launch env (pins + custom slot), or
``None`` for a bare subscription launch.
:param launch_model: The model the launch pins via ``--model``, or
``None``.
:returns: None.
"""
config = _read_json_file(bridge_dir / _CONFIG_FILE)
if not isinstance(config, dict):
return
model_env = {
key: launch_env[key]
for key in MODEL_VOCABULARY_ENV_VARS
if launch_env is not None and launch_env.get(key)
}
changed = False
if model_env and config.get("model_env") != model_env:
config["model_env"] = model_env
changed = True
if launch_model and config.get("launch_model") != launch_model:
config["launch_model"] = launch_model
changed = True
if changed:
_write_json_file(bridge_dir / _CONFIG_FILE, config)
def read_bridge_id(bridge_dir: Path) -> str | None:
"""
Read the opaque bridge id from bridge config.
@@ -1356,22 +1462,18 @@ def build_hook_settings(
"command": command,
}
# ``MessageDisplay`` fires once per streamed assistant-text chunk and
# Claude blocks on the hook, so it gets a dedicated stdlib-only
# appender module instead of the heavier observer ``hook`` above —
# the per-chunk subprocess must stay cheap. It just appends the
# chunk to ``<bridge_dir>/message_deltas.jsonl``; the forwarder tails
# that file and publishes ``response.output_text.delta`` events.
message_display_command_parts = [
python,
"-I",
"-m",
"omnigent.claude_native_message_display_hook",
"--bridge-dir",
str(bridge_dir),
]
# Claude blocks on the hook, so the hot path must not even pay an
# interpreter spawn: a /bin/sh appender writes Claude's raw payload
# (flattened to one line — JSON strings never carry literal newlines)
# to ``message_deltas.jsonl``. The reader parses records by key and
# skips non-delta lines, so raw envelopes need no Python-side shaping.
deltas_quoted = shlex.quote(str(bridge_dir / MESSAGE_DELTAS_FILE))
message_display_hook = {
"type": "command",
"command": shlex.join(message_display_command_parts),
"command": (
"p=$(cat | tr -d '\\r\\n'); "
f'[ -n "$p" ] && printf \'%s\\n\' "$p" >> {deltas_quoted}; :'
),
}
hooks: dict[str, list[_JsonObject]] = {
"SessionStart": [{"hooks": [session_start_hook]}],
@@ -1462,19 +1564,43 @@ def build_hook_settings(
hooks["PermissionRequest"] = [{"hooks": [permission_hook]}]
# Policy-gate native Claude Code tools, not just relay/MCP tools.
evaluate_policy_command_parts = [
python,
"-I",
"-m",
"omnigent.claude_native_hook",
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
# The hook is a bare curl against the relay's evaluate-policy
# endpoint (which owns all transformation and verdict logic), so
# Claude's blocking tool-call path pays no interpreter spawn. The
# relay's coordinates are re-read from tool_relay.env on every
# event, so hooks survive runner restarts. Before the relay exists
# (it starts in the background at session create — a very early
# hook can beat it) or when curl fails, the same stdin is
# replayed into the Python hook, which owns the direct-server
# path and the phase-aware fail-closed contract — exactly the
# pre-curl behavior.
relay_env_quoted = shlex.quote(str(bridge_dir / _TOOL_RELAY_ENV_FILE))
evaluate_policy_python = shlex.join(
[
python,
"-I",
"-m",
"omnigent.claude_native_hook",
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
)
evaluate_policy_command = (
"p=$(cat); "
f"if [ -r {relay_env_quoted} ]; then . {relay_env_quoted}; "
"out=$(printf '%s' \"$p\" | curl -sf --max-time 86400 "
'-H "Authorization: Bearer $OMNIGENT_RELAY_TOKEN" '
"-H 'Content-Type: application/json' --data-binary @- "
'"$OMNIGENT_RELAY_URL/hook/claude/evaluate-policy" 2>/dev/null) '
"&& { printf '%s' \"$out\"; exit 0; }; fi; "
f"printf '%s' \"$p\" | {evaluate_policy_python}"
)
evaluate_policy_hook: _JsonObject = {
"type": "command",
"command": shlex.join(evaluate_policy_command_parts),
"command": evaluate_policy_command,
}
# In bypassPermissions mode PermissionRequest never fires, so
# AskUserQuestion needs its own PreToolUse hook to surface the
# form. It's a no-op in other modes to avoid double-surfacing.
@@ -1559,20 +1685,20 @@ def build_hook_settings(
if api_key_helper:
settings["apiKeyHelper"] = api_key_helper
# Override Claude Code's statusLine so we receive its stdin (the
# only place ``context_window`` surfaces). Chain to whatever the
# user had globally so claude-hud / their bar still renders.
status_parts = [
python,
"-I",
"-m",
"omnigent.claude_native_status",
"--bridge-dir",
str(bridge_dir),
]
# only place ``context_window`` surfaces). A /bin/sh shim captures
# the raw payload atomically (no interpreter spawn on Claude's
# blocking statusLine path — the forwarder normalizes it into
# ``context.json``) and chains to whatever the user had globally so
# claude-hud / their bar still renders.
raw_quoted = shlex.quote(str(bridge_dir / CONTEXT_RAW_FILE))
status_command = (
f"p=$(cat); printf '%s' \"$p\" > {raw_quoted}.$$.tmp"
f" && mv -f {raw_quoted}.$$.tmp {raw_quoted}"
)
chain_command = read_user_status_line_command()
if chain_command is not None:
status_parts.extend(["--chain", chain_command])
settings["statusLine"] = {"type": "command", "command": shlex.join(status_parts)}
status_command += f"; printf '%s' \"$p\" | ( {chain_command} )"
settings["statusLine"] = {"type": "command", "command": status_command}
return settings
@@ -1730,6 +1856,9 @@ def augment_claude_args(
)
if append_system_prompt:
args.extend(["--append-system-prompt", append_system_prompt])
# Imported here: bundle-skills parsing rides the spec graph; launch-only.
from omnigent.inner.bundle_skills import claude_native_skill_args
args.extend(
claude_native_skill_args(
bundle_dir,
@@ -2142,11 +2271,14 @@ def read_transcript_items_since(
Claude Code writes append-only JSONL records whose ``message``
payloads include user prompts, assistant text, native tool calls,
and native tool results. This parser intentionally ignores
metadata records (title, file-history, permission mode, system
bookkeeping) and raw ``thinking`` blocks, while translating the
user-visible semantic records into Omnigent item types the web UI
already understands.
and native tool results. This parser intentionally renders no
conversation item for metadata records (title, file-history,
permission mode, system bookkeeping) or raw ``thinking`` blocks,
while translating the user-visible semantic records into Omnigent
item types the web UI already understands. Some metadata is still
read for out-of-band mirroring rather than dropped outright a
``custom-title`` record surfaces on
:attr:`TranscriptReadResult.latest_custom_title`.
:param transcript_path: Claude transcript path, e.g.
``"/home/user/.claude/projects/x/session.jsonl"``.
@@ -2207,6 +2339,7 @@ def read_transcript_items_since_with_position(
active_settled_id = settled_response_id
latest_usage: dict[str, int] | None = None
latest_model: str | None = None
latest_custom_title: str | None = None
for record in read_result.records:
if record.text is None:
continue
@@ -2236,6 +2369,9 @@ def read_transcript_items_since_with_position(
model = _model_from_transcript_entry(entry)
if model is not None:
latest_model = model
custom_title = _custom_title_from_transcript_entry(entry)
if custom_title is not None:
latest_custom_title = custom_title
return TranscriptReadResult(
line_cursor=read_result.line_cursor,
byte_offset=read_result.byte_offset,
@@ -2243,6 +2379,7 @@ def read_transcript_items_since_with_position(
items=items,
latest_usage=latest_usage,
latest_model=latest_model,
latest_custom_title=latest_custom_title,
)
@@ -2295,6 +2432,7 @@ def read_transcript_items_from_offset(
active_settled_id = settled_response_id
latest_usage: dict[str, int] | None = None
latest_model: str | None = None
latest_custom_title: str | None = None
for record in read_result.records:
if record.text is None:
continue
@@ -2325,6 +2463,9 @@ def read_transcript_items_from_offset(
model = _model_from_transcript_entry(entry)
if model is not None:
latest_model = model
custom_title = _custom_title_from_transcript_entry(entry)
if custom_title is not None:
latest_custom_title = custom_title
return TranscriptReadResult(
line_cursor=read_result.line_cursor,
byte_offset=read_result.byte_offset,
@@ -2332,6 +2473,7 @@ def read_transcript_items_from_offset(
items=items,
latest_usage=latest_usage,
latest_model=latest_model,
latest_custom_title=latest_custom_title,
)
@@ -2871,6 +3013,11 @@ def inject_user_message(
(see :func:`_wait_for_claude_prompt_ready`). The second gate closes
a race on freshly-created sessions where the first message would
otherwise be typed into a still-booting TUI and silently dropped.
Between the two, any surface the person left covering the composer
from the embedded terminal a ctrl+r history search, a hand-opened
``/model`` picker is dismissed with Escape
(see :func:`_restore_occupied_input`), so the message reclaims the
input box instead of typing into that surface.
Delivered as one bracketed paste via ``tmux load-buffer`` (from a
temp file) + ``paste-buffer -p`` so interior newlines ride as raw CR
@@ -2904,6 +3051,11 @@ def inject_user_message(
after repeated submit Enters (message not delivered).
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
# A ctrl+r history search or hand-opened /model picker left covering
# the composer swallows everything typed below — and can hide the
# prompt glyph, wedging the readiness gate — so reclaim the input box
# before waiting on it.
_restore_occupied_input(info["socket_path"], info["tmux_target"])
# tmux.json only means the tmux session exists; Claude Code's input
# box mounts a few seconds later. Block until the prompt renders so
# the first message isn't typed into a still-booting TUI and dropped.
@@ -3106,10 +3258,16 @@ def kill_session(
there is no live session to kill.
:returns: None.
:raises RuntimeError: If the tmux target is not advertised in
time, or if the ``tmux kill-session`` invocation fails.
time, or if ``tmux kill-session`` fails for an unexpected reason.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
try:
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
except RuntimeError as exc:
detail = str(exc).lower()
if "can't find session" in detail or "no server running on" in detail:
return
raise
def inject_slash_command(
@@ -3123,6 +3281,11 @@ def inject_slash_command(
"""
Type a Claude Code slash command into the tmux pane and submit it.
A surface the person left covering the composer from the embedded
terminal (ctrl+r history search, hand-opened ``/model`` picker) is
dismissed first see :func:`_restore_occupied_input` so the
command cannot be typed into it.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:param command: Single-line slash command including the leading
@@ -3159,6 +3322,10 @@ def inject_slash_command(
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
# Same reclaim as inject_user_message: a ctrl+r search or hand-opened
# /model picker left covering the composer would swallow the C-u and
# the typed command.
_restore_occupied_input(socket_path, tmux_target)
# ``C-u`` clears any draft the user is mid-typing; otherwise the
# paste below concatenates with their text and Enter submits
# ``<their-draft>/effort high`` as a turn. Unlike Escape it does
@@ -3266,6 +3433,171 @@ def _confirm_tui_dialog(
return False
def _permission_mode_from_pane(pane: str) -> str | None:
"""
Read Claude Code's current permission mode off a captured pane.
The footer (`` auto mode on``, `` plan mode on``, ...) always sits
below the input box's closing rule, so the scan starts there rather than
at a fixed offset from the bottom: the footer's height scales with
concurrent subagents, which a fixed window cannot bound (the same reason
:func:`_claude_prompt_rendered` anchors on :func:`_is_box_rule`). Anchoring
also excludes transcript text structurally a mode name Claude echoed
while *discussing* modes sits above the box and can't be misread as live.
:param pane: Captured pane text from :func:`_capture_pane`.
:returns: The ``--permission-mode`` value for the rendered footer,
e.g. ``"auto"``, or ``None`` when no footer is visible (the
pane is mid-repaint, or the mode is one with no footer).
"""
lines = [line for line in pane.splitlines() if line.strip()]
# Below the last box rule is the footer region. With no rule the input box
# isn't mounted; fall back to the tail so a footer still reads during boot.
last_rule = max((i for i, line in enumerate(lines) if _is_box_rule(line)), default=None)
region = lines[last_rule + 1 :] if last_rule is not None else lines[-_PROMPT_SCAN_TAIL_LINES:]
for line in reversed(region):
for mode, footer in _PERMISSION_MODE_FOOTERS.items():
if footer in line:
return mode
return None
def _read_settled_permission_mode(
socket_path: str,
tmux_target: str,
*,
previous: str | None = None,
) -> str | None:
"""
Poll the pane until its permission-mode footer settles.
A shift+tab repaints the footer asynchronously, so an immediate
capture can read nothing or, worse, still read the PREVIOUS mode
and make the cycler believe the keystroke did nothing. Passing
*previous* waits for the footer to actually change.
:param socket_path: Absolute path to the tmux socket.
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:param previous: Mode read before the keystroke that prompted this
read, e.g. ``"plan"``. The pane keeps rendering it until the TUI
repaints, so a read that returns it is treated as not-yet-settled
and retried; ``None`` accepts the first mode seen (the initial
read, where there is nothing to change from).
:returns: The rendered mode, or ``None`` if none appeared or the
footer never moved off *previous* before
:data:`_MODE_FOOTER_SETTLE_TIMEOUT_S`.
"""
deadline = time.monotonic() + _MODE_FOOTER_SETTLE_TIMEOUT_S
while True:
mode = _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
if mode is not None and mode != previous:
return mode
if time.monotonic() >= deadline:
# Timed out: report the last mode seen so a pane that legitimately
# stayed put is distinguished from one with no footer at all.
return mode
time.sleep(_MODE_FOOTER_POLL_INTERVAL_S)
def set_permission_mode(
bridge_dir: Path,
*,
mode: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> str:
"""
Switch the running Claude terminal to *mode* by cycling shift+tab.
Claude Code has no non-interactive way to set a live session's mode
(``--permission-mode`` is launch-only, ``/permissions`` is an interactive
dialog, settings load at startup), so this drives the TUI's shift+tab
cycle, reading the mode footer after each press. The cycle is walked
rather than computed: its width varies with which optional modes are
enabled, so a fixed press count could land on the wrong mode.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:param mode: Target ``--permission-mode`` value, one of
:data:`CYCLEABLE_PERMISSION_MODES`, e.g. ``"auto"``.
:param timeout_s: Seconds to wait for ``tmux.json`` to be
advertised by the runner, e.g. ``30.0``.
:returns: The mode now rendered in the pane (== *mode*).
:raises ValueError: If *mode* is not cycle-reachable.
:raises RuntimeError: If the tmux target is not advertised in time,
a ``tmux`` invocation fails, the pane never renders a mode
footer, or the target is not reached within
:data:`_MODE_CYCLE_MAX_PRESSES` presses (the mode is not in
this session's cycle).
"""
if mode not in CYCLEABLE_PERMISSION_MODES:
raise ValueError(
f"permission mode {mode!r} cannot be switched on a running session; "
f"expected one of {sorted(CYCLEABLE_PERMISSION_MODES)}"
)
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
# The footer only renders once the input box is mounted; without
# this gate a shift+tab sent mid-boot is dropped and the read below
# reports a mode the keystroke never reached.
_wait_for_claude_prompt_ready(socket_path, tmux_target, timeout_s=timeout_s)
current = _read_settled_permission_mode(socket_path, tmux_target)
if current is None:
pane = _capture_pane(socket_path, tmux_target)
raise RuntimeError(
"Claude Code did not render a permission-mode footer, so its current "
f"mode could not be read.{_format_terminal_failure_tail(pane)}"
)
seen = [current]
for _ in range(_MODE_CYCLE_MAX_PRESSES):
if current == mode:
return current
# No ``-l``: tmux must interpret ``BTab`` as the shift+tab key.
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "BTab")
settled = _read_settled_permission_mode(socket_path, tmux_target, previous=current)
if settled is not None:
current = settled
seen.append(current)
if current == mode:
return current
raise RuntimeError(
f"Could not switch Claude Code to {mode!r} mode: cycled shift+tab "
f"{_MODE_CYCLE_MAX_PRESSES} times and only reached {sorted(set(seen))}. "
"The mode is not available in this session's cycle."
)
def confirm_dialog_if_open(bridge_dir: Path, *, hint: str) -> bool:
"""
Accept the *hint* dialog iff it is on screen RIGHT NOW; never blind-Enter.
Loop-safe building block for watchers that outlive a single injection
(a mid-turn ``/model`` queues in Claude's composer and pops its confirm
dialog only when the turn settles minutes later). Unlike
:func:`_confirm_tui_dialog` there is no timeout fallback Enter, so
calling this every few seconds can never type into a surface that is
not the named dialog.
:param bridge_dir: Bridge directory path.
:param hint: Text the dialog renders, e.g.
:data:`SWITCH_MODEL_DIALOG_HINT`.
:returns: ``True`` when the dialog was on screen and confirmed.
"""
try:
info = _wait_for_tmux_info(bridge_dir, timeout_s=1.0)
except (RuntimeError, OSError):
return False
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
try:
pane = _capture_pane(socket_path, tmux_target)
if hint not in pane:
return False
_confirm_and_verify_dialog_closed(socket_path, tmux_target, hint=hint)
except (RuntimeError, OSError):
return False
return True
def _confirm_and_verify_dialog_closed(
socket_path: str,
tmux_target: str,
@@ -3514,6 +3846,55 @@ def claude_pane_ready(bridge_dir: Path) -> bool:
return _claude_prompt_rendered(pane)
def _restore_occupied_input(socket_path: str, tmux_target: str) -> None:
"""
Dismiss a terminal-opened surface occupying Claude's input box.
A person can leave the composer covered from the embedded terminal
the ctrl+r prompt-history search, or a hand-opened ``/model`` picker
(:data:`_OCCUPIED_INPUT_HINTS`). Keystrokes injected while one is up
land in that surface instead of the chat input: the history search
filters on the pasted text and its Enter replays whatever old prompt
is selected. Each surface documents Escape as its dismissal ("Esc to
cancel"), closing it without committing anything and restoring the
empty input box, so the web-UI message wins the pane.
Escape is only sent while a hint is verifiably in the current
capture never blind, because on the bare composer Escape interrupts
an in-flight turn. An empty (torn) capture means "unknown" and gets
no Escape. A swallowed Escape is re-sent while the surface remains,
spaced by :data:`_OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S`.
Best-effort: a surface that outlives
:data:`_OCCUPIED_INPUT_DISMISS_TIMEOUT_S` is left on screen and the
caller's readiness gate or delivery verification fails loud, exactly
as it did before this restore existed.
:param socket_path: Absolute path to the tmux socket.
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:returns: None.
"""
deadline = time.monotonic() + _OCCUPIED_INPUT_DISMISS_TIMEOUT_S
last_escape: float | None = None
while True:
pane = _capture_pane(socket_path, tmux_target)
hint = next((text for text in _OCCUPIED_INPUT_HINTS if text in pane), None)
if hint is None:
return
now = time.monotonic()
if now >= deadline:
_logger.warning(
"claude-native: input box still occupied (%r) after %.1fs; proceeding",
hint,
_OCCUPIED_INPUT_DISMISS_TIMEOUT_S,
)
return
if last_escape is None or now - last_escape >= _OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S:
_logger.info("claude-native: dismissing %r covering the input box", hint)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Escape")
last_escape = now
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
def _claude_prompt_rendered(pane: str) -> bool:
"""
Return whether Claude Code's input prompt is rendered in a pane.
@@ -3707,7 +4088,7 @@ def _wait_for_claude_prompt_ready(
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
:returns: None.
:raises RuntimeError: If the prompt never renders within
:raises ClaudePromptTimeout: If the prompt never renders within
*timeout_s* (Claude failed to boot). The message carries a poll
count, how many of those polls saw an empty capture, and the tail
of the last non-empty capture the loop actually observed (see
@@ -3744,7 +4125,7 @@ def _wait_for_claude_prompt_ready(
# session is alive but capture-pane came back blank); non-empty captures
# with no box point at Claude never rendering the prompt (a boot crash,
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
raise RuntimeError(
raise ClaudePromptTimeout(
f"Claude Code terminal did not become ready within {timeout_s}s "
f"(input prompt never rendered in {polls} polls, "
f"{empty_polls} empty captures). The message was not delivered."
@@ -3800,7 +4181,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
:param bridge_dir: Bridge directory path.
:param timeout_s: Seconds to wait, e.g. ``30.0``.
:returns: ``{"socket_path": ..., "tmux_target": ...}``.
:raises RuntimeError: If the file never appears with valid
:raises TmuxSessionNotAdvertised: If the file never appears with valid
``socket_path`` and ``tmux_target`` fields.
"""
deadline = time.monotonic() + timeout_s
@@ -3812,7 +4193,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
if isinstance(socket_path, str) and isinstance(tmux_target, str):
return {"socket_path": socket_path, "tmux_target": tmux_target}
time.sleep(0.05)
raise RuntimeError(
raise TmuxSessionNotAdvertised(
"Claude terminal tmux target is not advertised yet. Wait for the "
"terminal to launch before sending messages from the web UI."
)
@@ -3856,6 +4237,7 @@ def start_tool_relay(
loop,
policy_client=policy_client,
session_id=session_id,
bridge_dir=bridge_dir,
)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
host, port = _http_server_host_port(httpd)
@@ -3869,6 +4251,13 @@ def start_tool_relay(
if session_id is not None:
relay_info["session_id"] = session_id
_write_json_file(bridge_dir / _TOOL_RELAY_FILE, relay_info)
# token_urlsafe's alphabet is [A-Za-z0-9_-], safe inside single quotes.
env_path = bridge_dir / _TOOL_RELAY_ENV_FILE
env_path.write_text(
f"OMNIGENT_RELAY_URL='http://{host}:{port}'\nOMNIGENT_RELAY_TOKEN='{token}'\n",
encoding="utf-8",
)
os.chmod(env_path, 0o600)
thread = threading.Thread(
target=httpd.serve_forever,
name="claude-native-tool-relay",
@@ -4069,6 +4458,7 @@ def _tool_relay_handler_factory(
*,
policy_client: httpx.AsyncClient | None = None,
session_id: str | None = None,
bridge_dir: Path | None = None,
) -> type[BaseHTTPRequestHandler]:
"""
Create an HTTP handler class for active-turn tool calls.
@@ -4103,7 +4493,11 @@ def _tool_relay_handler_factory(
:returns: None.
"""
if self.path not in ("/tool", "/policies/evaluate"):
if self.path not in (
"/tool",
"/policies/evaluate",
"/hook/claude/evaluate-policy",
):
self.send_error(HTTPStatus.NOT_FOUND)
return
if self.headers.get("Authorization") != f"Bearer {token}":
@@ -4113,6 +4507,9 @@ def _tool_relay_handler_factory(
if payload is None:
self.send_error(HTTPStatus.BAD_REQUEST)
return
if self.path == "/hook/claude/evaluate-policy":
self._handle_hook_evaluate(payload)
return
if self.path == "/policies/evaluate":
self._handle_policy_evaluate(payload)
return
@@ -4125,6 +4522,89 @@ def _tool_relay_handler_factory(
arguments = {}
self._send_json(_run_relay_tool(tool_executor, loop, name, arguments))
def _respond_hook_output(self, output: dict[str, object] | None) -> None:
"""Answer a hook-evaluate request with final hook output JSON.
:param output: Hook output dict, or ``None`` for "no opinion"
(empty body Claude proceeds).
"""
raw = b"" if output is None else json.dumps(output).encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if raw:
self.wfile.write(raw)
def _handle_hook_evaluate(self, payload: _JsonObject) -> None:
"""Serve one Claude policy hook event end to end.
The hook subprocess is a bare curl: this endpoint does the
payloadEvaluationRequest transform, the upstream evaluate
call, and the EvaluationResponsehook-output transform, so the
blocking hook path pays no interpreter spawn. Responses are
always 200; enforcement failures are expressed as fail-closed
hook output.
"""
# Heavy policy imports stay off this module's import path (hook
# subprocesses import it); the relay runs inside the runner
# process where these modules are already loaded.
from omnigent.native_policy_hook import (
evaluation_response_to_hook_output,
fail_closed_hook_output,
hook_payload_to_evaluation_request,
)
raw_event = payload.get("hook_event_name")
hook_event = raw_event if isinstance(raw_event, str) else ""
if policy_client is None or session_id is None:
self._respond_hook_output(None)
return
eval_request = hook_payload_to_evaluation_request(hook_event, payload)
if eval_request is None:
self._respond_hook_output(None)
return
context = eval_request["event"]["context"]
context["harness"] = "claude-native"
if bridge_dir is not None:
status_model = read_claude_status_model(bridge_dir)
if status_model:
context["model"] = status_model
# Stable re-attach id: a retried long-poll reattaches to the
# same parked ASK instead of raising a second approval card.
request_body = {
**eval_request,
"_omnigent_elicitation_id": f"elicit_evaluate_{secrets.token_hex(16)}",
}
import urllib.parse as _up
url = f"/v1/sessions/{_up.quote(session_id, safe='')}/policies/evaluate"
verdict: object = None
last_error: str | None = None
for attempt in range(3):
if attempt:
time.sleep(0.4)
future = asyncio.run_coroutine_threadsafe(
policy_client.post(url, json=request_body), loop
)
try:
resp = future.result(timeout=86400.0)
except Exception as exc: # noqa: BLE001 — shaped fail-closed below
last_error = str(exc).strip() or type(exc).__name__
continue
if resp.status_code != HTTPStatus.OK:
last_error = f"server returned HTTP {resp.status_code}"
continue
try:
verdict = json.loads(resp.content)
except (ValueError, TypeError):
last_error = "malformed EvaluationResponse body"
break
if not isinstance(verdict, dict) or not verdict.get("result"):
self._respond_hook_output(fail_closed_hook_output(hook_event, last_error))
return
self._respond_hook_output(evaluation_response_to_hook_output(hook_event, verdict))
def _handle_policy_evaluate(self, payload: _JsonObject) -> None:
if policy_client is None or session_id is None:
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE)
@@ -4692,6 +5172,14 @@ def _build_tools(config: _JsonObject) -> tuple[dict[str, Tool], Callable[[], Non
:returns: ``(tools, close_tools)`` where ``close_tools``
releases any helper processes.
"""
# Imported here, not at module top: this drags the tools/spec/pydantic
# graph (~300 ms of interpreter startup), and this module is on the
# import path of every per-chunk/per-tool-call Claude hook subprocess.
# Only the bridge MCP server (launch path) ever builds these tools.
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.os_env import create_os_environment
from omnigent.tools.builtins.os_env import build_os_env_tools
workspace_raw = config.get("workspace")
workspace = Path(workspace_raw) if isinstance(workspace_raw, str) and workspace_raw else None
os_env: OSEnvironment | None = None
@@ -4766,6 +5254,32 @@ def _model_from_transcript_entry(entry: _JsonObject) -> str | None:
return None
def _custom_title_from_transcript_entry(entry: _JsonObject) -> str | None:
"""
Return ``customTitle`` from a ``custom-title`` transcript record.
Claude Code appends this metadata record when the operator renames
the session from the pane (``/rename``). It carries no ``message``,
so it renders no conversation item; the forwarder mirrors it onto the
Omnigent session title instead.
Only the explicit user title is read. Claude also writes an
``aiTitle`` record holding its own generated summary, which is
ignored here because Omnigent runs its own background titler and two
auto-titlers would fight over one field.
:param entry: One decoded transcript JSONL record.
:returns: The operator-chosen title, e.g. ``"auth-refactor"``, or
``None`` for other record types and blank values.
"""
if entry.get("type") != "custom-title":
return None
title = entry.get("customTitle")
if isinstance(title, str) and title.strip():
return title
return None
def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
"""
Read the most recent statusLine snapshot from ``context.json``.
@@ -4800,6 +5314,31 @@ def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
return parsed
def read_permission_mode(bridge_dir: Path) -> str | None:
"""
Read the permission mode currently rendered in the Claude pane.
Non-blocking and best-effort: the forwarder calls this every poll so an
in-pane shift+tab switch reaches the web UI, which otherwise never sees it
(only UI-driven switches stamp the mode label). Returns ``None`` when the
terminal isn't up or the pane shows no mode footer, so a caller can treat
"unknown" as "no fresh observation" rather than a change.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:returns: The ``--permission-mode`` value rendered in the pane, e.g.
``"auto"``, or ``None`` when it cannot be determined.
"""
payload = _read_json_file(bridge_dir / _TMUX_FILE)
if not isinstance(payload, dict):
return None
socket_path = payload.get("socket_path")
tmux_target = payload.get("tmux_target")
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
return None
return _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
def read_claude_status_model(bridge_dir: Path) -> str | None:
"""
Read the active model id from the statusLine snapshot ``context.json``.
+247 -80
View File
@@ -36,6 +36,7 @@ from omnigent.claude_native_bridge import (
read_hook_events_from_offset,
read_hook_events_since_with_position,
read_message_deltas_from_offset,
read_permission_mode,
read_transcript_items_from_offset,
read_transcript_items_since_with_position,
read_transcript_path,
@@ -45,6 +46,7 @@ from omnigent.claude_native_bridge import (
write_active_session_id,
)
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
from omnigent.claude_native_status import sync_raw_status_context
from omnigent.entities.session_resources import terminal_resource_id
from omnigent.reasoning_effort import CLAUDE_EFFORTS, EFFORT_CLEAR_VALUES
@@ -81,6 +83,11 @@ _SUBAGENT_IDLE_QUIESCENCE_S = 5.0
# ``agent-<id>.jsonl`` transcript.
_SUBAGENT_META_GLOB = "agent-*.meta.json"
_DEFAULT_POLL_INTERVAL_S = 0.25
# Minimum spacing between permission-mode pane reads. Unlike the model mirror
# (which reads a JSON file), this spawns a ``tmux capture-pane`` subprocess, so
# it runs well below the poll interval; a mode switch is a human action and 2s
# of lag is imperceptible.
_PERMISSION_MODE_POLL_INTERVAL_S = 2.0
# Hard ceiling on one poll iteration of the forward loop. A silently stalled
# await anywhere in the pipeline used to stop mirroring, status and the busy
# signal forever; the deadline cancels the stall (the traceback names it) and
@@ -489,16 +496,15 @@ class _ForwardDedupeState:
:param usage: Last ``message.usage`` snapshot POSTed via
``external_session_usage``, or ``None`` if none yet.
:param context_window: Last context-window POSTed, or ``None``.
:param observed_model: Last tier alias seen in the transcript,
sticky across polls (the incremental window often carries no
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
seen.
:param posted_model: Last tier alias POSTed via
``external_model_change``. Seeded from the first observation
WITHOUT a POST so a passive spawn default never overwrites a
pending silent model handoff; only a later in-TUI switch is
mirrored. Left behind ``observed_model`` on a failed POST so the
next poll retries. ``None`` until the first observation.
:param observed_model: Last VERBATIM model seen (statusLine or
transcript), sticky across polls (the incremental window often
carries no fresh ``message.model``), e.g.
``"claude-opus-4-8[1m]"``. ``None`` until first seen.
:param posted_model: Last verbatim model POSTed via
``external_model_change``. Every observation posts the first
one is the launch report that seeds the session's
``reported_model``. Left behind ``observed_model`` on a failed
POST so the next poll retries. ``None`` until the first post.
:param posted_cost: Last DISPLAY cost (USD) POSTed as
``cumulative_cost_usd`` the statusLine total ``S`` verbatim.
``None`` until the first cost post. Used to dedupe so a steady
@@ -509,6 +515,15 @@ class _ForwardDedupeState:
from ``posted_cost`` because it advances mid-turn (with in-flight
sub-agent spend) while ``S`` stays frozen. ``None`` until first
post.
:param observed_title: Last ``custom-title`` seen in the transcript,
sticky across polls, e.g. ``"auth-refactor"``. ``None`` until the
operator runs ``/rename``.
:param posted_title: Last title POSTed via
``external_session_title``. Unlike ``posted_model`` this is NOT
seeded without a POST a ``custom-title`` record only exists
because the operator renamed the session, so the first
observation is a real change worth mirroring. Left behind
``observed_title`` on a failed POST so the next poll retries.
:param recorded_token_usage: Last token counters recorded on a
``claude_native.usage`` span as ``gen_ai.usage.*``. Deduped
separately from ``usage`` because that snapshot also moves on
@@ -522,6 +537,8 @@ class _ForwardDedupeState:
recorded_token_usage: dict[str, int] | None = None
observed_model: str | None = None
posted_model: str | None = None
observed_title: str | None = None
posted_title: str | None = None
# Last DISPLAY cost (USD) POSTed as ``cumulative_cost_usd`` — the
# statusLine total ``S`` verbatim (matches /cost in the Claude TUI).
# Kept to suppress duplicate posts when S hasn't advanced.
@@ -531,6 +548,13 @@ class _ForwardDedupeState:
# sub-agent spend so the gate can block mid-turn. Separate baseline
# because it can advance while ``posted_cost`` (S) is frozen.
posted_policy_cost: float | None = None
# Last permission mode POSTed as ``external_permission_mode_change`` —
# mirrors the launch mode and any in-pane shift+tab switch, neither of
# which the web UI can observe on its own.
posted_permission_mode: str | None = None
# Monotonic deadline before which the next pane read is skipped, so the
# subprocess spawn runs at _PERMISSION_MODE_POLL_INTERVAL_S, not every poll.
permission_mode_next_read: float = 0.0
# Turn-settle latch driving the scheduled-wake boundary. The Stop edge
# records the ended turn's id as PENDING; it activates (moves to
# ``settled_response_id``) only once a fully-consumed transcript batch
@@ -782,6 +806,9 @@ async def forward_claude_transcript_to_session(
# the per-poll cost reconciliation from re-parsing unchanged transcripts.
# Reset on /clear and /fork rotations alongside ``dedupe``.
cost_cache: dict[Path, _TranscriptCostCacheEntry] = {}
# (mtime_ns, size) of the statusLine shim's raw capture last normalized
# into context.json (see claude_native_status.sync_raw_status_context).
status_raw_sig: tuple[int, int] | None = None
# Per-process latch: once we PATCH the conversation with the
# Claude-native session id, never PATCH again. Persists for the
# lifetime of the forwarder task; the server's idempotence handles
@@ -794,9 +821,9 @@ async def forward_claude_transcript_to_session(
task_statuses: dict[str, str] = {}
task_order: list[str] = []
timeout = httpx.Timeout(_POST_TIMEOUT_S)
async with httpx.AsyncClient(
base_url=base_url, headers=headers, auth=auth, timeout=timeout
) as client:
from omnigent.cli_auth import open_server_client
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
while True:
try:
async with asyncio.timeout(_FORWARD_LOOP_STALL_DEADLINE_S):
@@ -901,6 +928,9 @@ async def forward_claude_transcript_to_session(
session_id=current_session_id,
bridge_dir=bridge_dir,
)
# Normalize the statusLine shim's raw capture into
# context.json (one stat when nothing changed).
status_raw_sig = sync_raw_status_context(bridge_dir, status_raw_sig)
transcript_path = read_transcript_path(bridge_dir)
if transcript_path is not None:
state = await _ensure_state_for_transcript(
@@ -994,6 +1024,14 @@ async def forward_claude_transcript_to_session(
bridge_dir=bridge_dir,
dedupe=dedupe,
)
# Same rationale for the permission mode: a shift+tab in
# the pane emits no event, so poll the footer.
await _forward_permission_mode_from_pane(
client=client,
session_id=current_session_id,
bridge_dir=bridge_dir,
dedupe=dedupe,
)
except asyncio.CancelledError:
raise
except TimeoutError:
@@ -3554,19 +3592,27 @@ async def _forward_available_items(
_http_status_for_log(exc),
exc_info=True,
)
# Mirror a TUI-side `/model` switch to the web picker. The transcript
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
# it to the picker's tier alias. This transcript-derived observation
# only fires when a turn produces a fresh ``message.model``, so it lags
# an in-pane switch by one turn — the per-poll statusLine sync
# (:func:`_forward_model_from_status`) is the primary, low-latency
# source; this stays as a fallback for cold-resume before the first
# statusLine render. Both share ``dedupe`` so neither double-posts.
# Report the transcript's model verbatim. This transcript-derived
# observation only fires when a turn produces a fresh
# ``message.model``, so it lags an in-pane switch by one turn — the
# per-poll statusLine sync (:func:`_forward_model_from_status`) is the
# primary, low-latency source; this stays as a fallback for cold-resume
# before the first statusLine render. Both share ``dedupe`` so neither
# double-posts.
await _post_model_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
alias=_model_alias_for(result.latest_model),
model=result.latest_model,
)
# Mirror a TUI-side `/rename` to the web session list. Claude writes the
# operator's title as a `custom-title` metadata record, which renders no
# conversation item, so this is the only path that surfaces it.
await _post_title_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
title=result.latest_custom_title,
)
return updated
@@ -4150,38 +4196,81 @@ def _gen_ai_usage_tokens(usage: Mapping[str, float | str] | None) -> dict[str, i
return tokens
def _model_alias_for(model: str | None) -> str | None:
async def _post_external_permission_mode_change(
client: httpx.AsyncClient,
*,
session_id: str,
mode: str,
) -> None:
"""
Collapse a concrete Claude model id to the picker's tier alias.
Post one ``external_permission_mode_change`` event to the Sessions API.
The web model picker speaks Claude Code's version-agnostic aliases
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
extra concrete-id slot ``"sonnet_5"`` (see
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
transcript records the resolved concrete id (e.g.
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
Mapping to the tier keeps the mirrored value in the picker's
vocabulary and makes a webTUI round-trip a no-op. The older Sonnet
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias it is the
default that row is bound to.
Lets the web mode picker reflect a shift+tab switch made inside the Claude
Code terminal, which Omnigent has no other way to observe.
:param model: Concrete model id from the transcript, e.g.
``"claude-opus-4-8"``; ``None`` when none observed yet.
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
``"haiku"`` when the id carries a known tier token, else ``None``
(the caller skips the post rather than surface an id the picker
can't render).
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
:param mode: Permission mode the pane now shows, e.g. ``"auto"``.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
if not model:
return None
lowered = model.lower()
if "sonnet-5" in lowered or "sonnet_5" in lowered:
return "sonnet_5"
for tier in ("fable", "opus", "sonnet", "haiku"):
if tier in lowered:
return tier
return None
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_permission_mode_change", "data": {"permission_mode": mode}},
)
resp.raise_for_status()
async def _forward_permission_mode_from_pane(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
dedupe: _ForwardDedupeState,
) -> None:
"""
Mirror the pane's permission-mode footer to the session label each poll.
A shift+tab pressed inside the TUI produces no event Omnigent can see, so
without this the web picker shows a stale mode until the next UI-driven
switch. Polling the footer is the only signal available: Claude Code emits
nothing on a mode change, and hook payloads only arrive on tool use.
The launch mode is posted too, not just later switches: a session started
in manual mode carries no ``--permission-mode`` arg and no mode label, so
with nothing posted the web picker has no mode to render and hides itself.
Best-effort and idempotent the server ignores a mode equal to the stored
label, an unchanged mode or unreadable pane is a no-op, and a failed POST
is retried next poll.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param bridge_dir: Native Claude bridge directory.
:param dedupe: Shared per-session dedupe state; mutated in place.
"""
# Throttled: this spawns a tmux subprocess, unlike the file-backed model
# mirror that shares this poll loop.
now = time.monotonic()
if now < dedupe.permission_mode_next_read:
return
dedupe.permission_mode_next_read = now + _PERMISSION_MODE_POLL_INTERVAL_S
mode = await asyncio.to_thread(read_permission_mode, bridge_dir)
if mode is None or mode == dedupe.posted_permission_mode:
return
try:
await _post_external_permission_mode_change(
client,
session_id=session_id,
mode=mode,
)
except httpx.HTTPError:
_logger.debug(
"external_permission_mode_change post failed; session=%s mode=%s",
session_id,
mode,
exc_info=True,
)
return
dedupe.posted_permission_mode = mode
async def _post_external_model_change(
@@ -4193,13 +4282,15 @@ async def _post_external_model_change(
"""
Post one ``external_model_change`` event to the Sessions API.
Lets the web model picker reflect a model switch made inside the
Claude Code terminal (a ``/model`` command or the in-TUI picker).
Reports the model the pane is actually on the launch's own model
included so every surface renders the harness's truth.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g.
``"conv_abc123"``.
:param model: Tier alias the session is now on, e.g. ``"opus"``.
:param model: The harness's VERBATIM model, e.g.
``"claude-opus-4-8[1m]"`` never collapsed to a picker alias
(a family word claims a generation the pane may not be on).
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
@@ -4209,45 +4300,121 @@ async def _post_external_model_change(
resp.raise_for_status()
async def _post_external_session_title(
client: httpx.AsyncClient,
*,
session_id: str,
title: str,
) -> None:
"""
Post one ``external_session_title`` event to the Sessions API.
Mirrors a ``/rename`` typed in the Claude Code pane onto the Omnigent
session title so the web session list stops showing the stale
auto-generated one.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g.
``"conv_abc123"``.
:param title: Operator-chosen title, e.g. ``"auth-refactor"``.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_title", "data": {"title": title}},
)
resp.raise_for_status()
async def _post_title_change_if_new(
client: httpx.AsyncClient,
*,
session_id: str,
dedupe: _ForwardDedupeState,
title: str | None,
) -> None:
"""
Mirror an observed ``/rename`` title to the session, deduped.
Unlike :func:`_post_model_change_if_new`, the FIRST observation is
posted rather than used to seed the baseline silently: a
``custom-title`` record exists only because the operator ran
``/rename``, so there is no passive spawn default to protect.
A steady-state poll reads only records past its byte cursor, so the
dedupe is not for the ordinary case it covers the cursor rewind /
restart path that re-reads an already-posted record, and it is what
makes the retry below safe to attempt on every poll.
Best-effort: a failed POST leaves ``posted_title`` behind
``observed_title`` so the next poll retries. ``observed_title`` is
sticky for exactly this reason the retry must survive polls whose
own window carries no rename.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param dedupe: Shared per-session dedupe state; mutated in place.
:param title: Title just observed, or ``None`` when this poll's
window carried no ``custom-title`` record. ``observed_title`` is
sticky, so ``None`` does not clear it a previously-observed but
unposted title is still retried here.
"""
if title is not None:
dedupe.observed_title = title
if dedupe.observed_title is None or dedupe.observed_title == dedupe.posted_title:
return
try:
await _post_external_session_title(
client,
session_id=session_id,
title=dedupe.observed_title,
)
dedupe.posted_title = dedupe.observed_title
except httpx.HTTPError:
# Leave posted_title behind observed_title so the next poll retries.
_logger.warning(
"Failed to mirror /rename to Omnigent session=%s; the web session "
"list may show a stale title until the next poll",
session_id,
exc_info=True,
)
async def _post_model_change_if_new(
client: httpx.AsyncClient,
*,
session_id: str,
dedupe: _ForwardDedupeState,
alias: str | None,
model: str | None,
) -> None:
"""
Mirror an observed model tier alias to ``model_override``, deduped.
Report the observed model to ``reported_model``, verbatim and deduped.
Shared by the transcript-driven path (:func:`_forward_available_items`)
and the statusLine-driven per-poll path
(:func:`_forward_model_from_status`). The FIRST observation is the
session's spawn default, not a switch, so it seeds the dedupe baseline
WITHOUT posting (posting it could clobber a pending silent model
handoff). Every later change posts ``external_model_change``. Both
callers pass the same ``dedupe`` so whichever observes a switch first
posts it and the other no-ops. Best-effort: a failed POST leaves
``posted_model`` behind ``observed_model`` so the next poll retries.
(:func:`_forward_model_from_status`). EVERY observation posts the
first one is the launch report that seeds the session's
``reported_model``, so surfaces show the pane's truth within seconds
of spawn; the server dedupes by equality, so a steady model costs one
POST total. Both callers pass the same ``dedupe`` so whichever
observes a change first posts it and the other no-ops. Best-effort: a
failed POST leaves ``posted_model`` behind ``observed_model`` so the
next poll retries.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param dedupe: Shared per-session dedupe state; mutated in place.
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
), or ``None`` when this source carried no recognizable model on
this poll. ``observed_model`` is sticky across polls, so passing
``None`` does NOT clear it it just means "no fresh observation,"
and a previously-observed-but-unposted model is still reconciled
(retried) here.
:param model: The harness's verbatim model just observed (e.g.
``"claude-opus-4-8[1m]"``), or ``None`` when this source carried
no model on this poll. ``observed_model`` is sticky across
polls, so passing ``None`` does NOT clear it it just means "no
fresh observation," and a previously-observed-but-unposted model
is still reconciled (retried) here.
"""
if alias is not None:
dedupe.observed_model = alias
if model is not None:
dedupe.observed_model = model
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
return
if dedupe.posted_model is None:
# First observation = the spawn default; seed the baseline without
# posting so it can't clobber a pending silent model handoff.
dedupe.posted_model = dedupe.observed_model
return
try:
await _post_external_model_change(
client,
@@ -4273,7 +4440,7 @@ async def _forward_model_from_status(
dedupe: _ForwardDedupeState,
) -> None:
"""
Mirror the statusLine-reported active model to ``model_override`` each poll.
Report the statusLine's active model to ``reported_model`` each poll.
Claude Code rewrites the statusLine stdin on every TUI render including
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
@@ -4285,8 +4452,9 @@ async def _forward_model_from_status(
turn later, which is what happened when the model was derived solely
from the next turn's transcript ``message.model``.
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
so a no-op when the model is unchanged.
The value posts VERBATIM the harness's own spelling, never collapsed
to a picker alias. Best-effort and idempotent: shares ``dedupe`` with
the transcript path, so a no-op when the model is unchanged.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
@@ -4297,12 +4465,11 @@ async def _forward_model_from_status(
if status_state is None:
return
model = status_state.get("model")
alias = _model_alias_for(model if isinstance(model, str) else None)
await _post_model_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
alias=alias,
model=model.strip() if isinstance(model, str) and model.strip() else None,
)
+92 -26
View File
@@ -11,8 +11,7 @@ import sys
import time
from collections.abc import Callable
from pathlib import Path
import httpx
from typing import TYPE_CHECKING
from omnigent.claude_native_bridge import (
BRIDGE_ID_LABEL_KEY,
@@ -28,17 +27,13 @@ from omnigent.claude_native_bridge import (
url_component,
write_active_session_id,
)
from omnigent.entities.session_resources import terminal_resource_id
from omnigent.native_policy_hook import (
_is_login_redirect_or_unauthorized,
evaluation_response_to_hook_output,
fail_closed_hook_output,
hook_payload_to_evaluation_request,
policy_hook_reauth,
post_evaluate_with_retry,
read_relay_policy_config,
relay_policy_evaluate_url,
)
# The observer path (the default, most frequent invocation — Claude blocks
# on it per Stop/UserPromptSubmit/TaskCreated/...) must not pay the
# httpx/policy import cost; those are imported inside the subcommands and
# helpers that actually speak HTTP.
if TYPE_CHECKING:
import httpx
# Client-side budget for the permission-request long-poll to AP. Held
# at one day so the hook subprocess waits ~indefinitely for a verdict
@@ -128,17 +123,23 @@ def _env_float(name: str, default: float) -> float:
# down server can no longer re-POST for a day. Overridable for operators who
# want more slack against a flaky upstream.
_PERMISSION_MAX_CONSECUTIVE_FAILURES = max(1, _env_int("OMNIGENT_HOOK_MAX_RETRIES", 8))
# httpx errors that mean the request never reached a live server (no response
# was ever begun). These are unambiguous hard failures — the server is down /
# unreachable, not holding a poll. Everything else under ``httpx.HTTPError``
# that is not a 4xx/5xx status (RemoteProtocolError, ReadError, ReadTimeout, …)
# means the connection was established and then severed mid-poll.
_NEVER_CONNECTED_ERRORS = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.PoolTimeout,
httpx.ProxyError,
)
def _never_connected_errors() -> tuple[type[Exception], ...]:
"""httpx errors meaning the request never reached a live server.
No response was ever begun unambiguous hard failures (the server is
down / unreachable), not a held poll. Everything else under
``httpx.HTTPError`` that is not a 4xx/5xx status (RemoteProtocolError,
ReadError, ReadTimeout, ) means the connection was established and
then severed mid-poll. A function, not a module constant, so the
hook's hot observer path never imports httpx.
"""
import httpx
return (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ProxyError)
# An established connection that drops in under this many seconds is treated as
# a flapping/crash-looping server (a hard failure), NOT a genuinely-parked poll
# a proxy severed. Comfortably below any real idle-proxy timeout (typically
@@ -365,6 +366,18 @@ def _rotate_session_on_clear(bridge_dir: Path) -> str | None:
if isinstance(raw_headers, dict)
else {}
)
import httpx
# Route the whole rotation sequence (GET old, POST /v1/sessions or /fork,
# PATCH new, DELETE old) to the replica holding this host's tunnel: a managed
# create/fork notifies the host inline over its pod-local tunnel, so an
# off-replica request can't reach it. This hook client carries no
# _RunnerDatabricksAuth, so key the reused headers dict from the runner-env
# host_id (databricks_request_headers reads OMNIGENT_RUNNER_SLICE_KEY when no
# explicit host_id; emitted only on the workspace mount).
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(ap_server_url))
try:
with httpx.Client(
headers=headers, timeout=httpx.Timeout(_SESSION_ROTATION_TIMEOUT_S)
@@ -411,6 +424,18 @@ def _rotate_session_on_fork(bridge_dir: Path) -> str | None:
if isinstance(raw_headers, dict)
else {}
)
import httpx
# Route the whole rotation sequence (GET old, POST /v1/sessions or /fork,
# PATCH new, DELETE old) to the replica holding this host's tunnel: a managed
# create/fork notifies the host inline over its pod-local tunnel, so an
# off-replica request can't reach it. This hook client carries no
# _RunnerDatabricksAuth, so key the reused headers dict from the runner-env
# host_id (databricks_request_headers reads OMNIGENT_RUNNER_SLICE_KEY when no
# explicit host_id; emitted only on the workspace mount).
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(ap_server_url))
try:
with httpx.Client(
headers=headers, timeout=httpx.Timeout(_SESSION_ROTATION_TIMEOUT_S)
@@ -489,6 +514,8 @@ def _create_clear_replacement_session(
)
bind_resp.raise_for_status()
from omnigent.entities.session_resources import terminal_resource_id
terminal_id = terminal_resource_id("claude", "main")
transfer_resp = client.post(
(
@@ -569,6 +596,8 @@ def _create_fork_replacement_session(
)
bind_resp.raise_for_status()
from omnigent.entities.session_resources import terminal_resource_id
terminal_id = terminal_resource_id("claude", "main")
transfer_resp = client.post(
(
@@ -648,7 +677,7 @@ def _post_hook_with_reattach(
Failure classification:
* **Hard failure count toward the cap.** A 5xx, or a connection that
never established (:data:`_NEVER_CONNECTED_ERRORS`), or an established
never established (:func:`_never_connected_errors`), or an established
connection that dropped in under :data:`_PERMISSION_HELD_POLL_FLOOR_S`
(a flapping/crash-looping server). This is the spin.
* **Held-poll sever reset the counter.** An established connection that
@@ -701,6 +730,10 @@ def _post_hook_with_reattach(
"_omnigent_elicitation_id": f"elicit_claude_{secrets.token_hex(16)}",
}
backoff_s = _PERMISSION_RETRY_INITIAL_BACKOFF_S
import httpx
from omnigent.native_policy_hook import _is_login_redirect_or_unauthorized
timeout = httpx.Timeout(_PERMISSION_TIMEOUT_S, connect=_PERMISSION_CONNECT_TIMEOUT_S)
# Absolute backstop: even a run of held-poll severs (which don't count
# toward the hard-failure cap) can't loop past the day-long human-answer
@@ -751,7 +784,7 @@ def _post_hook_with_reattach(
# Classify by HOW it failed, not by elapsed time (a proxy severs a
# legitimately-held poll in seconds-to-minutes, so wall-clock can't
# tell it from a down server — #1782 Polly review).
never_connected = isinstance(exc, _NEVER_CONNECTED_ERRORS)
never_connected = isinstance(exc, _never_connected_errors())
held_s = time.monotonic() - attempt_started
# Hard failure iff the server was never reached, OR an established
# connection dropped so fast it's a flap rather than a parked poll.
@@ -801,6 +834,8 @@ def _main_permission_request(argv: list[str]) -> int:
:returns: Process exit code. Returns ``0`` on transport failures so
Claude Code falls back to its terminal prompt.
"""
from omnigent.native_policy_hook import policy_hook_reauth
args = _parse_permission_args(argv)
raw = sys.stdin.read()
try:
@@ -826,6 +861,13 @@ def _main_permission_request(argv: list[str]) -> int:
raw_headers = config.get("ap_auth_headers")
if isinstance(raw_headers, dict):
headers = {str(key): str(value) for key, value in raw_headers.items()}
# A permission request raises a web elicitation that parks in the pod-local
# registry on the replica holding this session's runner tunnel; an unkeyed
# POST lands elsewhere and the approval is silently lost. Key from the
# runner-env host_id (reads OMNIGENT_RUNNER_SLICE_KEY; workspace mount only).
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(ap_server_url))
url = (
f"{ap_server_url.rstrip('/')}/v1/sessions/"
f"{url_component(session_id)}/hooks/permission-request"
@@ -867,6 +909,8 @@ def _main_ask_user_question(argv: list[str]) -> int:
:returns: Process exit code. Returns ``0`` on any failure so Claude Code
falls back to its terminal TUI prompt rather than blocking.
"""
from omnigent.native_policy_hook import policy_hook_reauth
args = _parse_permission_args(argv)
raw = sys.stdin.read()
try:
@@ -898,6 +942,12 @@ def _main_ask_user_question(argv: list[str]) -> int:
raw_headers = config.get("ap_auth_headers")
if isinstance(raw_headers, dict):
headers = {str(key): str(value) for key, value in raw_headers.items()}
# Same as the permission-request hook: the elicitation parks in the pod-local
# registry on the session's tunnel replica, so key the POST from the
# runner-env host_id or an off-replica landing silently drops the prompt.
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(ap_server_url))
url = (
f"{ap_server_url.rstrip('/')}/v1/sessions/"
f"{url_component(session_id)}/hooks/permission-request"
@@ -995,6 +1045,16 @@ def _main_evaluate_policy(argv: list[str]) -> int:
:returns: Process exit code. Always ``0`` blocking verdicts
are expressed via the JSON output, not exit codes.
"""
from omnigent.native_policy_hook import (
evaluation_response_to_hook_output,
fail_closed_hook_output,
hook_payload_to_evaluation_request,
policy_hook_reauth,
post_evaluate_with_retry,
read_relay_policy_config,
relay_policy_evaluate_url,
)
args = _parse_evaluate_policy_args(argv)
raw = sys.stdin.read()
try:
@@ -1048,6 +1108,12 @@ def _main_evaluate_policy(argv: list[str]) -> int:
raw_headers = config.get("ap_auth_headers")
if isinstance(raw_headers, dict):
headers = {str(k): str(v) for k, v in raw_headers.items()}
# This posts to the session's policy registry on the replica holding its
# tunnel; key from the runner-env host_id so it isn't misrouted. (The
# relay branch above targets a relay token URL, so it needs no key.)
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(ap_server_url))
session_component = url_component(session_id)
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{session_component}/policies/evaluate"
reauth = policy_hook_reauth(ap_server_url, headers)
+5 -3
View File
@@ -47,6 +47,8 @@ import os
from dataclasses import dataclass
from pathlib import Path
from omnigent.process_logging import data_dir
# Env-var override for the persistent state root. Reserved for tests
# (and for advanced users who want to put state on a non-default
# volume). When unset, the module falls back to
@@ -91,8 +93,8 @@ def _claude_native_state_root() -> Path:
Honors the :data:`_STATE_ROOT_ENV_VAR` override so tests can
point the state tree at a per-test ``tmp_path`` without
clobbering the user's real home directory. Production callers
leave the env unset and get the default
clobbering the user's state. Otherwise the root follows
``OMNIGENT_DATA_DIR``, falling back to
``~/.omnigent/claude-native``.
Lazy: created on first write, never on read (the resume / picker
@@ -105,7 +107,7 @@ def _claude_native_state_root() -> Path:
override = os.environ.get(_STATE_ROOT_ENV_VAR)
if override:
return Path(override)
return Path.home() / ".omnigent" / "claude-native"
return data_dir() / "claude-native"
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
+114 -55
View File
@@ -20,6 +20,93 @@ import tempfile
from pathlib import Path
_CONTEXT_FILE = "context.json"
# Raw statusLine stdin captured by the shell shim the settings now install
# (no Python spawn on Claude's blocking statusLine path). The forwarder
# normalizes it into ``context.json`` via :func:`sync_raw_status_context`.
CONTEXT_RAW_FILE = "context_raw.json"
def normalize_status_payload(payload: dict[str, object]) -> dict[str, object] | None:
"""
Extract the ``context.json`` record from a raw statusLine payload.
:param payload: Decoded statusLine stdin JSON from Claude Code.
:returns: The record to persist, or ``None`` when the payload carries
no usable ``context_window`` (nothing worth recording).
"""
context = payload.get("context_window")
if not isinstance(context, dict):
return None
size = context.get("context_window_size")
usage = context.get("current_usage")
if not isinstance(size, int) or size <= 0:
return None
record: dict[str, object] = {"context_window_size": size}
if isinstance(usage, dict):
record["current_usage"] = usage
used_pct = context.get("used_percentage")
if isinstance(used_pct, (int, float)):
record["used_percentage"] = used_pct
# Claude Code's statusLine stdin carries a top-level ``cost`` block with
# its own cumulative session billing; the forwarder reports it because
# claude-native produces no ``response.completed`` cost events.
cost = payload.get("cost")
if isinstance(cost, dict):
total_cost = cost.get("total_cost_usd")
if (
isinstance(total_cost, (int, float))
and not isinstance(total_cost, bool)
and total_cost >= 0
):
record["total_cost_usd"] = float(total_cost)
# The active model, rewritten on every render — including right after an
# in-pane ``/model`` switch — so gates see the switch before the next turn.
model = payload.get("model")
model_id: str | None = None
if isinstance(model, dict):
raw_model = model.get("id") or model.get("display_name")
if isinstance(raw_model, str) and raw_model.strip():
model_id = raw_model.strip()
elif isinstance(model, str) and model.strip():
model_id = model.strip()
if model_id is not None:
record["model"] = model_id
return record
def sync_raw_status_context(
bridge_dir: Path,
last_sig: tuple[int, int] | None,
) -> tuple[int, int] | None:
"""
Normalize the shim's raw statusLine capture into ``context.json``.
Called from the forwarder's poll loop. Cheap when nothing changed —
one ``stat`` against the remembered ``(mtime_ns, size)`` signature.
:param bridge_dir: Bridge directory shared with the statusLine shim.
:param last_sig: Signature returned by the previous call, or ``None``.
:returns: The new signature to carry forward (unchanged on a miss or
an unparseable file, so the next poll retries).
"""
raw_path = bridge_dir / CONTEXT_RAW_FILE
try:
stat = raw_path.stat()
except OSError:
return last_sig
sig = (stat.st_mtime_ns, stat.st_size)
if sig == last_sig:
return last_sig
try:
payload = json.loads(raw_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return last_sig
if not isinstance(payload, dict):
return sig
record = normalize_status_payload(payload)
if record is not None:
_write_record_atomic(bridge_dir, record)
return sig
def main(argv: list[str] | None = None) -> int:
@@ -67,71 +154,43 @@ def _write_context_atomic(bridge_dir: Path, payload: dict[str, object]) -> None:
Persist the statusLine payload's context fields to ``context.json``.
Atomic write so the forwarder never observes a half-written file.
Soft-fails (writes nothing) when ``context_window`` is missing or
malformed there's nothing useful to record.
Soft-fails (writes nothing) when the payload carries no usable
``context_window``. Retained for older bridge dirs whose settings
still invoke this module; new settings install a shell shim and the
forwarder normalizes via :func:`sync_raw_status_context`.
:param bridge_dir: Bridge directory shared with the forwarder.
:param payload: Decoded statusLine stdin JSON.
"""
context = payload.get("context_window")
if not isinstance(context, dict):
record = normalize_status_payload(payload)
if record is None:
return
size = context.get("context_window_size")
usage = context.get("current_usage")
if not isinstance(size, int) or size <= 0:
return
record: dict[str, object] = {"context_window_size": size}
if isinstance(usage, dict):
record["current_usage"] = usage
used_pct = context.get("used_percentage")
if isinstance(used_pct, (int, float)):
record["used_percentage"] = used_pct
# Claude Code's statusLine stdin carries a top-level ``cost`` block with
# its own cumulative session billing. Capture ``total_cost_usd`` so the
# forwarder can report it (claude-native produces no ``response.completed``
# event, so the Omnigent relay's cost accumulation never runs for it).
cost = payload.get("cost")
if isinstance(cost, dict):
total_cost = cost.get("total_cost_usd")
if (
isinstance(total_cost, (int, float))
and not isinstance(total_cost, bool)
and total_cost >= 0
):
record["total_cost_usd"] = float(total_cost)
# Claude Code's statusLine stdin carries the active model as a ``model``
# block (``{"id": "claude-opus-4-8", "display_name": "Opus"}``), rewritten
# on every render — including right after an in-pane ``/model`` switch.
# Capture the concrete id so the forwarder can mirror the switch to
# ``model_override`` on the next poll, before the user's next message,
# rather than waiting for the next turn's transcript to reveal the model
# (which lagged model-gated policies by one turn). Defensive about the
# shape: accept a ``{id|display_name}`` dict or a bare string.
model = payload.get("model")
model_id: str | None = None
if isinstance(model, dict):
raw_model = model.get("id") or model.get("display_name")
if isinstance(raw_model, str) and raw_model.strip():
model_id = raw_model.strip()
elif isinstance(model, str) and model.strip():
model_id = model.strip()
if model_id is not None:
record["model"] = model_id
try:
bridge_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".context-", dir=str(bridge_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(record, handle, separators=(",", ":"))
os.replace(tmp_path, str(bridge_dir / _CONTEXT_FILE))
except OSError:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
_write_record_atomic(bridge_dir, record)
except OSError as exc:
print(f"omnigent claude status: write failed: {exc}", file=sys.stderr)
def _write_record_atomic(bridge_dir: Path, record: dict[str, object]) -> None:
"""
Atomically write one normalized record to ``context.json``.
:param bridge_dir: Bridge directory shared with the forwarder.
:param record: Normalized record from :func:`normalize_status_payload`.
:raises OSError: When the temp-file write or replace fails.
"""
bridge_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".context-", dir=str(bridge_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(record, handle, separators=(",", ":"))
os.replace(tmp_path, str(bridge_dir / _CONTEXT_FILE))
except OSError:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
def _chain(command: str, stdin_payload: str) -> None:
"""
Exec the user's pre-existing statusLine command, piping our stdin.
+434 -66
View File
@@ -70,7 +70,7 @@ from omnigent.inner import _proc, ui
from omnigent.integration_daemon import IntegrationDaemon
from omnigent.json_types import JsonObject as _JsonObject
from omnigent.onboarding.sandboxes import available_providers as _sandbox_providers
from omnigent.process_logging import LOG_LEVEL_ENV_VAR, LOG_TO_STDERR_ENV_VAR
from omnigent.process_logging import LOG_LEVEL_ENV_VAR, LOG_TO_STDERR_ENV_VAR, data_dir, env_truthy
if TYPE_CHECKING:
import socket
@@ -1781,6 +1781,98 @@ def _set_log_to_stderr(
return value
def _finish_cli_profile(profiler: Any, output_path: Path) -> None: # type: ignore[explicit-any]
"""Stop a requested cProfile run and render a focused bottleneck summary."""
import pstats
profiler.disable()
profiler.dump_stats(output_path)
stats = pstats.Stats(profiler)
stats_state = vars(stats)
raw_stats = cast(
dict[tuple[str, int, str], tuple[int, int, float, float, object]],
stats_state["stats"],
)
total_time = float(stats_state["total_tt"])
total_calls = int(stats_state["total_calls"])
primitive_calls = int(stats_state["prim_calls"])
package_root = Path(__file__).resolve().parent
source_root = package_root.parent
# (filename, line, function, primitive calls, total calls, self, cumulative)
rows = [(*key, cc, nc, tt, ct) for key, (cc, nc, tt, ct, _) in raw_stats.items()]
def _location(filename: str, line: int, function: str) -> str:
path = Path(filename).resolve()
try:
display = str(path.relative_to(source_root))
except ValueError:
display = path.name
return f"{display}:{line}({function})"
def _duration(seconds: float) -> str:
if seconds < 0.01:
return f"{seconds * 1_000:.2f} ms"
if seconds < 1:
return f"{seconds * 1_000:.1f} ms"
return f"{seconds:.3f} s"
def _print_rows(
title: str,
selected: list[tuple[str, int, str, int, int, float, float]],
) -> None:
click.echo(f"\n{title}", err=True)
click.echo(f" {'self':>9} {'cumulative':>10} {'calls':>9} function", err=True)
for filename, line, function, primitive, calls, self_time, cumulative in selected[:10]:
call_count = str(calls) if calls == primitive else f"{calls}/{primitive}"
click.echo(
f" {_duration(self_time):>9} {_duration(cumulative):>10} "
f"{call_count:>9} {_location(filename, line, function)}",
err=True,
)
omnigent_rows = [
row for row in rows if Path(row[0]).resolve().is_relative_to(package_root) and row[6] > 0
]
omnigent_rows.sort(key=lambda row: row[6], reverse=True)
self_time_rows = [row for row in omnigent_rows if row[5] > 0]
self_time_rows.sort(key=lambda row: row[5], reverse=True)
click.echo(
f"\nCLI profile: {_duration(total_time)}, "
f"{total_calls:,} calls ({primitive_calls:,} primitive)",
err=True,
)
_print_rows("Top Omnigent call paths", omnigent_rows)
_print_rows("Top Omnigent functions by self time", self_time_rows)
click.echo(f"\nFull profile data: {output_path}", err=True)
def _start_cli_profile(
ctx: click.Context,
_param: click.Parameter,
value: bool,
) -> bool:
"""Start cProfile early and finish it after the selected command exits."""
if not value:
return value
import cProfile
timestamp = time.strftime("%Y%m%d-%H%M%S")
microseconds = time.time_ns() // 1_000 % 1_000_000
profile_dir = data_dir() / "profiles"
profile_dir.mkdir(parents=True, exist_ok=True)
output_path = profile_dir / (f"omnigent-cli-{timestamp}-{microseconds:06d}-{os.getpid()}.prof")
profiler = cProfile.Profile()
profiler.enable()
ctx.call_on_close(lambda: _finish_cli_profile(profiler, output_path))
# Click closes callbacks last-in-first-out, so measurement stops before
# rendering the summary above.
ctx.call_on_close(profiler.disable)
return value
def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, bool]:
"""Remove global logging flags before run-shorthand rewriting."""
debug_logging = False
@@ -1801,6 +1893,17 @@ def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, boo
@click.group(cls=_OmnigentCLI)
@click.option(
"--profiling",
is_flag=True,
is_eager=True,
expose_value=False,
callback=_start_cli_profile,
help=(
"Profile CLI execution, print a summary, and write a timestamped .prof file. "
"Place before COMMAND."
),
)
@click.option(
"--debug",
"debug_logging",
@@ -1930,6 +2033,44 @@ def _warn_deprecated_harness_path_env_vars() -> None:
)
REQUIRE_WRAPPER_ENV = "OMNIGENT_REQUIRE_WRAPPER"
WRAPPER_COMMAND_ENV = "OMNIGENT_WRAPPER_COMMAND"
WRAPPER_BYPASS_ENV = "OMNIGENT_WRAPPER_BYPASS"
def _wrapper_guard_error(env: Mapping[str, str], prog: str) -> str | None:
"""Return the block message when a naked ``omni`` call is refused, else ``None``.
A deployment that wraps the CLI (e.g. ``isaac omni``) sets
``OMNIGENT_REQUIRE_WRAPPER`` so direct calls are refused; the wrapper sets
``OMNIGENT_WRAPPER_BYPASS`` around its own invocation to pass through, and
``OMNIGENT_WRAPPER_COMMAND`` names the command to suggest instead.
"""
if not env_truthy(env.get(REQUIRE_WRAPPER_ENV)):
return None
if env_truthy(env.get(WRAPPER_BYPASS_ENV)):
return None
redirect = (env.get(WRAPPER_COMMAND_ENV) or "").strip()
if redirect:
detail = f"Use `{redirect}` instead, or set {WRAPPER_BYPASS_ENV}=1 to run it directly."
else:
detail = f"Set {WRAPPER_BYPASS_ENV}=1 to run it directly."
return f"Error: running `{prog}` directly is disabled in this environment.\n{detail}"
def _enforce_wrapper_guard() -> None:
"""Exit early when a naked ``omni``/``omnigent`` call is blocked by an operator."""
# argv[0] is the console-script name (``omni``/``omnigent``); ``python -m
# omnigent`` reports ``__main__.py``, so fall back to the canonical name.
prog = os.path.basename(sys.argv[0])
if not prog or prog == "__main__.py":
prog = "omnigent"
message = _wrapper_guard_error(os.environ, prog)
if message is not None:
click.echo(message, err=True)
raise SystemExit(2)
def main() -> None:
"""
Console-script entry point for ``omnigent``.
@@ -1961,6 +2102,11 @@ def main() -> None:
install_crash_handler(app_name="omnigent", repo="omnigent-ai/omnigent")
# Operators can force all use through a wrapper (e.g. `isaac omni`) by
# setting OMNIGENT_REQUIRE_WRAPPER; the wrapper sets OMNIGENT_WRAPPER_BYPASS
# to pass through. Refuse naked calls before any work happens.
_enforce_wrapper_guard()
cwd = os.getcwd()
if cwd not in sys.path:
sys.path.insert(0, cwd)
@@ -1988,7 +2134,17 @@ def main() -> None:
# intentionally tiny (currently only help/version); runner flags live on
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
# users can type the natural no-AGENT launcher form.
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
if (
argv
and argv[0].startswith("-")
and argv[0]
not in {
"--help",
"-h",
"--version",
"--profiling",
}
):
argv = ["run", *argv]
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
@@ -2158,6 +2314,10 @@ def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
# help listing subcommands, not the legacy argparse help.
if argv[0] in {"--help", "-h", "--version"}:
return False
# A root profiling flag may precede an eager help/version flag or stand
# alone. These are valid Click invocations, not removed ad-hoc chat.
if all(token in {"--profiling", "--help", "-h", "--version"} for token in argv):
return False
# Skip leading flags to find the first positional. If all
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
# treat it as removed ad-hoc chat rather than handing it to click
@@ -2185,7 +2345,7 @@ def _runner_loopback_host(host: str) -> str:
return "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host
_HOST_PID_PATH = Path.home() / ".omnigent" / "host.pid"
_HOST_PID_PATH = data_dir() / "host.pid"
# host.pid records the daemon PID + the "target" it serves: a normalized
@@ -2394,6 +2554,7 @@ def _daemon_host_online(record: _HostDaemonRecord, *, timeout_s: float = 2.0) ->
method="GET",
path=f"/v1/hosts/{url_component(host_id)}",
timeout_s=timeout_s,
host_id=host_id,
)
if result.status_code != 200 or not isinstance(result.body, dict):
return False
@@ -2819,6 +2980,7 @@ def _spawn_host_daemon_process(
proc = subprocess.Popen(
args,
env=env,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=log_fh,
**_proc.spawn_kwargs(),
@@ -2887,7 +3049,7 @@ def _foreground_daemon_record(
started_at=int(time.time()),
host_id=host_id,
resolved_server_url=server_url.rstrip("/") if mode == "local" else None,
config_sig=server_config_signature(),
config_sig=server_config_signature(include_features=mode == "local"),
)
@@ -2927,11 +3089,13 @@ def _claim_foreground_daemon_record(
"""
conflict = _live_daemon_conflict(record)
if conflict is not None:
# server_url is None in local mode; "" makes the hint say --server "".
stop_command = _host_stop_command(conflict.server_url or "")
raise click.ClickException(
"A host daemon is already running for this server "
f"(pid={conflict.pid}, target={conflict.target}). "
"Run `omnigent host status` to inspect it or "
"`omnigent host stop --server ...` to stop it first."
f"Run `omnigent host status` to inspect it or `{stop_command}` "
"to stop it first."
)
previous = _find_daemon_record(record.target)
if previous is not None and not _pid_alive(previous.pid):
@@ -3007,14 +3171,15 @@ def _ensure_host_daemon(server_url: str | None) -> bool:
mode_args = ["--local"] if not server_url else ["--server", server_url]
args = [sys.executable, "-m", "omnigent.host._daemon_entry", *mode_args]
spawned = _spawn_host_daemon_process(
args=args, env=_build_host_daemon_env(server_url=server_url)
args=args,
env=_build_host_daemon_env(server_url=server_url),
)
if spawned is None:
return False
_persist_spawned_daemon(
target=target,
spawned=spawned,
config_sig=server_config_signature(),
config_sig=server_config_signature(include_features=not server_url),
)
return decision.config_changed
@@ -3114,9 +3279,10 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
today. A non-200 answer that carries the Databricks edge signature
(302 to the workspace OAuth page, or a DatabricksRealm 401) means
the run would otherwise die much later with an opaque "non-JSON
response (status=302)" traceback from the session-create call. On a
TTY we run the same flow ``omnigent login`` would and continue;
headless invocations get the exact command to run instead.
response (status=302)" traceback from the session-create call. First,
it asks the SDK for a fresh workspace token; only then does a TTY run
the same flow ``omnigent login`` would, while headless invocations get
the exact command to run instead.
Non-Databricks postures are deliberately left alone: local accounts
servers auto-authenticate downstream (magic-link redeem), and
@@ -3136,11 +3302,12 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
import httpx as _httpx
from omnigent.chat import _remote_headers
from omnigent.cli_auth import load_databricks_org_id, store_databricks_auth
try:
probe = _httpx.get(
f"{server}/v1/me",
headers=_remote_headers(server_url=server),
headers=_remote_headers(server_url=server, host_id=None),
timeout=10.0,
)
except _httpx.HTTPError:
@@ -3152,6 +3319,13 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
workspace_host = _databricks_workspace_login_target(server, probe)
if workspace_host is None:
return
org_id = load_databricks_org_id(server)
token = _databricks_workspace_token(workspace_host)
if token is not None:
refreshed_probe = _verify_databricks_server_token(server, token, org_id)
if refreshed_probe.status_code == 200:
store_databricks_auth(server, workspace_host, org_id=org_id)
return
login_cmd = f"omnigent login {server}"
if non_interactive or not sys.stdin.isatty():
raise click.ClickException(
@@ -3159,11 +3333,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
f"HTTP {probe.status_code}). Run `{login_cmd}` and retry."
)
click.echo(f"Not signed in to {server} — running `{login_cmd}` first.")
# Recover the ``?o=`` selector from a prior login record so a re-login
# still targets the right workspace.
from omnigent.cli_auth import load_databricks_org_id
_databricks_login(server, workspace_host, org_id=load_databricks_org_id(server))
_databricks_login(server, workspace_host, org_id=org_id)
def _ensure_backend(server: str | None) -> str:
@@ -3199,10 +3369,24 @@ def _ensure_backend(server: str | None) -> str:
# otherwise the session-create call deep in the REPL bring-up
# surfaces the edge redirect as an opaque non-JSON-response
# traceback.
#
# The auth probe (GET /v1/me, ~0.65s) and the daemon tunnel start
# (~2s) are independent — run them concurrently so the auth check
# is hidden under the longer daemon wait.
import concurrent.futures
server = _resolve_server_url(server)
_ensure_databricks_server_auth(server)
with runner_startup_progress(initial_message=STARTUP_PHASE_CONNECTING_REMOTE):
_ensure_host_daemon(server)
with (
runner_startup_progress(initial_message=STARTUP_PHASE_CONNECTING_REMOTE),
concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool,
):
auth_future = pool.submit(_ensure_databricks_server_auth, server)
daemon_future = pool.submit(_ensure_host_daemon, server)
# Raise auth errors before daemon errors: a login failure is
# more actionable than a daemon-connect failure that would
# have been caused by the same missing credentials.
auth_future.result()
daemon_future.result()
return server
# Local mode: the daemon spawns (or reuses) a persistent local Omnigent server.
# On a cold start this is the longest silent gap between the user pressing
@@ -3427,7 +3611,11 @@ def _start_cli_runner_process(
try:
with child_logging_popen_kwargs(env) as logging_kwargs:
runner_proc: subprocess.Popen[bytes] = subprocess.Popen(
[sys.executable, "-m", "omnigent.runner._entry"],
# This runner inherits the CLI's cwd, so -P is what stops a
# checkout you launched from shadowing the installed omnigent
# (the daemon and zygote spawns do the same). _entry re-adds the
# cwd afterwards, keeping spec-declared local tools importable.
[sys.executable, "-P", "-m", "omnigent.runner._entry"],
env=env,
stdout=log_fh,
stderr=log_fh,
@@ -3770,6 +3958,10 @@ def server(
cfg = _load_config(config_path)
# Let the server-config reader (branding) see the same ``-c`` file.
if config_path:
os.environ["OMNIGENT_CONFIG"] = str(Path(config_path).resolve())
# CLI args take precedence over config file, which takes precedence
# over defaults.
db_uri = database_uri or cfg.get("database_uri", _default_db_uri())
@@ -5668,6 +5860,7 @@ def import_session_command(
import httpx
from omnigent.chat import _remote_headers
from omnigent.conversation_browser import conversation_url
from omnigent.session_import import (
ImportSource,
SessionImportNotFoundError,
@@ -5736,7 +5929,7 @@ def import_session_command(
response = httpx.post(
f"{base_url}/v1/imports",
json=payload,
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
timeout=120.0,
)
except httpx.RequestError as exc:
@@ -5773,13 +5966,17 @@ def import_session_command(
)
continue
imported_count += 1
# Surface the browser URL, not the bare id, so the user can open the
# imported session straight into the web (where it offers the resume
# picker). Maps a Databricks API base to its workspace SPA link.
session_link = conversation_url(base_url, session_id)
if is_batch:
click.echo(
f"Imported {item_count} item(s) from {current_source_session_id} "
f"into {session_id}."
f"into {session_link}"
)
else:
click.echo(f"Imported {item_count} item(s) into {session_id}.")
click.echo(f"Imported {item_count} item(s) into {session_link}")
if is_batch:
click.echo(f"\nImported: {imported_count}")
@@ -5934,7 +6131,7 @@ def usage(limit: int, server: str | None, as_json: bool) -> None:
with httpx.Client(
base_url=base_url,
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
timeout=60.0,
trust_env=_trust_env_for(base_url),
) as client:
@@ -6018,7 +6215,7 @@ def session_export(session_id: str, output: str | None, server: str | None) -> N
with httpx.Client(
base_url=base_url,
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
timeout=30.0,
trust_env=_trust_env_for(base_url),
) as client:
@@ -6260,7 +6457,7 @@ def session_import(input_path: str, title: str | None, server: str | None) -> No
with httpx.Client(
base_url=base_url,
headers=_remote_headers(server_url=base_url),
headers=_remote_headers(server_url=base_url, host_id=None),
timeout=120.0,
trust_env=_trust_env_for(base_url),
) as client:
@@ -6479,10 +6676,24 @@ def _materialize_harness_launcher_file(
if acp_agent is not None:
if canonical != "acp":
raise click.ClickException("An ephemeral ACP agent requires the acp harness.")
executor["acp_agent"] = {
# Embed all fields that affect spawn so the remote server sees the same
# agent config as the client. Preserve session_id_mode, send_model,
# omnigent_mcp, env_passthrough, and model.
agent_dict: dict[str, object] = {
"name": acp_agent.name,
"command": acp_agent.command,
}
if acp_agent.model is not None:
agent_dict["model"] = acp_agent.model
if acp_agent.session_id_mode != "server":
agent_dict["session_id_mode"] = acp_agent.session_id_mode
if acp_agent.send_model:
agent_dict["send_model"] = acp_agent.send_model
if not acp_agent.omnigent_mcp:
agent_dict["omnigent_mcp"] = acp_agent.omnigent_mcp
if acp_agent.env_passthrough:
agent_dict["env_passthrough"] = list(acp_agent.env_passthrough)
executor["acp_agent"] = agent_dict
raw = {
"name": display_name,
@@ -6776,7 +6987,7 @@ def _dispatch_native_terminal_harness(
session_id = _resolve_latest_conversation_id(
base_url=server,
agent_name=native_agent.agent_name,
headers=_remote_headers(server_url=server),
headers=_remote_headers(server_url=server, host_id=None),
)
# The user explicitly asked to continue; if there's nothing to continue,
# fail loud rather than silently starting fresh (matches the REPL's
@@ -7558,6 +7769,17 @@ def run(
raise click.ClickException("--from-openclaw cannot be combined with --harness.")
acp_agent = _resolve_openclaw_run_agent(from_openclaw)
harness = f"acp:{acp_agent.slug}"
# Client-side harness resolution for acp:<slug>: resolve the slug before
# embedding in the spec so it works with remote servers. The server would resolve
# the slug from ITS config (if present), but fails when the agent is only
# configured locally on the client. Embedding the resolved agent avoids this gap.
# Preserve the existing config-lookup path as fallback; specs authored by hand
# still use it.
if acp_agent is None and harness is not None and harness.startswith("acp:"):
from omnigent.onboarding.acp_auth import resolve_acp_agent
slug = harness.split(":", 1)[1]
acp_agent = resolve_acp_agent(slug)
direct_server_cli = (
target is None
and server_from_cli
@@ -7780,6 +8002,9 @@ def _prompt_stop_local_server() -> None:
# server URL, missing credentials) leaves nothing on the terminal, so we wait
# this long and surface its log instead of falsely reporting success.
_BACKGROUND_HOST_GRACE_S = 2.0
# A detached process isn't ready merely because its PID survived. Wait until
# the server confirms the host row and live tunnel are online.
_BACKGROUND_HOST_REGISTRATION_GRACE_S = 30.0
def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
@@ -7792,18 +8017,50 @@ def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
deadline = time.time() + _BACKGROUND_HOST_GRACE_S
while True:
if not _pid_alive(record.pid):
from omnigent._runner_startup import format_runner_log_tail
log_path = Path(record.log_path) if record.log_path else None
raise click.ClickException(
"The host daemon exited immediately after starting."
f"{format_runner_log_tail(log_path)}"
f"{_background_host_log_detail(record.log_path)}"
)
if time.time() >= deadline:
return
time.sleep(0.1)
def _background_host_log_detail(log_path: str | None) -> str:
"""Return the host log path and a short failure tail."""
if log_path is None:
return ""
path = Path(log_path)
detail = f"\nHost log: {path}"
try:
tail = path.read_bytes()[-4096:].decode("utf-8", errors="replace").strip()
except OSError:
return detail
if tail:
detail += "\n--- host log tail ---\n" + "\n".join(tail.splitlines()[-12:])
return detail
def _confirm_background_host_registered(record: _HostDaemonRecord) -> None:
"""Wait until the detached daemon completes server registration."""
deadline = time.monotonic() + _BACKGROUND_HOST_REGISTRATION_GRACE_S
while True:
if not _pid_alive(record.pid):
raise click.ClickException(
"The host daemon exited before registering with the server."
f"{_background_host_log_detail(record.log_path)}"
)
if _daemon_host_online(record, timeout_s=1.0):
return
if time.monotonic() >= deadline:
raise click.ClickException(
"The host daemon started but did not register with the server "
f"within {_BACKGROUND_HOST_REGISTRATION_GRACE_S:.0f}s."
f"{_background_host_log_detail(record.log_path)}"
)
time.sleep(0.2)
def _run_background_host(
server: str | None,
*,
@@ -7831,7 +8088,7 @@ def _run_background_host(
:param non_interactive: When ``True``, never launch the browser login
fail with the ``omnigent login`` hint instead.
:raises click.ClickException: If the daemon cannot be spawned, exits
immediately after starting, or (local mode) never serves its local
immediately, fails to register, or (local mode) never serves its local
Omnigent server.
"""
if server:
@@ -7841,30 +8098,40 @@ def _run_background_host(
_ensure_host_daemon(server or None)
record = _find_daemon_record(target)
if record is None:
# No record for this target: either the live local-mode daemon already
# serves the requested URL, or the spawn itself failed.
# A local daemon may already own the requested URL under its local
# registry key. It is reusable only after its host is online too.
if _local_daemon_serves_target(target, server or None):
click.echo(f"The local host daemon already serves {target}.")
return
local_record = _find_daemon_record(_LOCAL_DAEMON_MARKER)
if local_record is not None:
_confirm_background_host_registered(local_record)
click.echo(f"The local host daemon already serves {target}.")
return
raise click.ClickException(
"Could not spawn the background host daemon. See ~/.omnigent/logs/host/ for details."
)
if previous is not None and previous.pid == record.pid:
headline = _cli_style("Host daemon already running", fg="yellow", bold=True)
else:
_confirm_background_host_alive(record)
headline = _cli_style("Started the host daemon in the background", fg="green", bold=True)
reused = previous is not None and previous.pid == record.pid
try:
if not reused:
_confirm_background_host_alive(record)
if record.mode == "local":
# The status probe needs the daemon-owned server's loopback URL.
server_url = _discover_local_server_url()
_update_daemon_resolved_server_url(target, server_url)
record = _find_daemon_record(target) or record
else:
server_url = target
_confirm_background_host_registered(record)
except click.ClickException:
if not reused:
with contextlib.suppress(click.ClickException):
_terminate_daemon(record, force=True)
raise
headline = _cli_style(
"Host daemon already running" if reused else "Started the host daemon in the background",
fg="yellow" if reused else "green",
bold=True,
)
click.echo(f"{headline} (pid {record.pid}).")
if record.mode == "local":
# A local-mode daemon owns the local Omnigent server, so this command is
# the whole "start everything" step — wait for that server and report
# its URL, otherwise the Web UI is unreachable without a follow-up
# `omnigent server status`. Resolved after the headline above so a cold
# start isn't a silent terminal.
server_url = _discover_local_server_url()
_update_daemon_resolved_server_url(target, server_url)
else:
server_url = target
_echo_host_field("server", _cli_style(server_url, fg="cyan"))
if record.log_path is not None:
_echo_host_field("log", _display_path(Path(record.log_path)))
@@ -8119,7 +8386,7 @@ def _selected_daemon_records(
# Databricks CLI). Within a single CLI invocation the token is valid, so
# resolving once and reusing it is safe. The lock serialises concurrent
# resolution for the same URL (two threads must not both pay the cost).
_host_http_headers_cache: dict[str, dict[str, str]] = {}
_host_http_headers_cache: dict[tuple[str, str | None], dict[str, str]] = {}
_host_http_headers_lock = threading.Lock()
@@ -8146,6 +8413,7 @@ def _host_http_json(
params: dict[str, str | int] | None = None,
json_body: _HostJsonObject | None = None,
timeout_s: float = 10.0,
host_id: str | None = None,
) -> _HostHttpResult:
"""
Send one management request to an Omnigent server.
@@ -8160,6 +8428,10 @@ def _host_http_json(
``{"type": "stop_session", "data": {}}``.
:param timeout_s: Request timeout in seconds, e.g. ``2.0`` for a
quick liveness probe. Defaults to ``10.0`` for management calls.
:param host_id: The host this request is scoped to (host-control, or a
host-backed session event like stop_session), so it reaches the replica
holding that host's tunnel. ``None`` for non-host-scoped calls; the
builder emits the routing header only on the workspace-hosted server.
:returns: Decoded HTTP result.
"""
import httpx
@@ -8167,11 +8439,17 @@ def _host_http_json(
from omnigent.chat import _remote_headers
try:
if base_url not in _host_http_headers_cache:
# Cache the resolved headers per (base_url, host_id): the auth resolution
# is the expensive part (token mint / CLI shell-out), and the slice-key
# varies by the host a call is scoped to, so both belong in the key.
cache_key = (base_url, host_id)
if cache_key not in _host_http_headers_cache:
with _host_http_headers_lock:
if base_url not in _host_http_headers_cache:
_host_http_headers_cache[base_url] = _remote_headers(server_url=base_url)
headers = _host_http_headers_cache[base_url]
if cache_key not in _host_http_headers_cache:
_host_http_headers_cache[cache_key] = _remote_headers(
server_url=base_url, host_id=host_id
)
headers = _host_http_headers_cache[cache_key]
with httpx.Client(
base_url=base_url,
headers=headers,
@@ -8386,12 +8664,22 @@ def _runner_online_map(
if isinstance((runner_id := session.get("runner_id")), str) and runner_id
}
)
# A runner is spawned on exactly one host; its status endpoint reads the
# in-memory tunnel registry, so the check must reach that host's replica or
# it reports the runner offline. The session rows carry each runner's host.
runner_host: dict[str, str] = {}
for session in sessions:
rid = session.get("runner_id")
host = session.get("host_id")
if isinstance(rid, str) and rid and isinstance(host, str) and host:
runner_host.setdefault(rid, host)
statuses: dict[str, bool | None] = {}
for runner_id in runner_ids:
result = _host_http_json(
base_url=base_url,
method="GET",
path=f"/v1/runners/{url_component(runner_id)}/status",
host_id=runner_host.get(runner_id),
)
if result.status_code == 200 and isinstance(result.body, dict):
online = result.body.get("online")
@@ -8472,6 +8760,7 @@ def _add_daemon_host_status(
base_url=base_url,
method="GET",
path=f"/v1/hosts/{url_component(host_id)}",
host_id=host_id,
)
if host_result.status_code == 200 and isinstance(host_result.body, dict):
status = host_result.body.get("status")
@@ -8952,11 +9241,27 @@ def _stop_session_on_server(
"""
from omnigent.claude_native_bridge import url_component
# This is a standalone CLI process with an empty session→host map, so read
# the session's host from its record first: the stop_session event is a
# server→runner forward and must reach the replica holding the runner's
# tunnel. The metadata GET itself is host-agnostic (served from any replica).
host_id: str | None = None
info = _host_http_json(
base_url=base_url,
method="GET",
path=f"/v1/sessions/{url_component(session_id)}",
)
if info.status_code == 200 and isinstance(info.body, dict):
host_value = info.body.get("host_id")
if isinstance(host_value, str) and host_value:
host_id = host_value
result = _host_http_json(
base_url=base_url,
method="POST",
path=f"/v1/sessions/{url_component(session_id)}/events",
json_body={"type": "stop_session", "data": {}},
host_id=host_id,
)
if result.status_code == 0:
raise click.ClickException(
@@ -9013,6 +9318,50 @@ def _stop_daemon_sessions(
return stopped
def _signal_daemon_pid(record: _HostDaemonRecord, sig: int) -> bool:
"""
Signal a daemon's recorded PID, tolerating a stale foreign entry.
A daemon registry record can outlive the process it names. The recorded
PID may since have been reused by an unrelated process often owned by
another user or the real daemon may have been started under a different
account (e.g. ``sudo``). In both cases the PID is no longer this user's
daemon and must not be killed.
``os.kill`` raises ``PermissionError`` (EPERM) when the caller lacks
permission to signal the target which, since we only ever signal our
own daemons, means the record is stale and points at someone else's
process. ``_pid_alive`` reports such a PID as alive (``psutil`` maps the
same permission failure to ``AccessDenied``), so without this guard
``_terminate_daemon`` would fall through to ``os.kill`` and crash on the
unsuppressed EPERM ``--force`` included, since it dies before the
SIGKILL path. Log a warning and treat the record as stale instead.
:param record: Daemon record whose PID should be signalled.
:param sig: Signal number to send, e.g. ``signal.SIGTERM``.
:returns: ``True`` if the record is stale and the caller should drop it
and stop (either the PID is not ours, or it already exited); ``False``
if the signal was delivered and termination should proceed as usual.
"""
try:
os.kill(record.pid, sig)
except ProcessLookupError:
# The process exited between the liveness check and the signal —
# nothing left to kill, so the record is stale.
return True
except PermissionError:
# Not our daemon: the record points at another user's process (PID
# reuse, or a daemon started under a different account). Drop the
# stale record and warn rather than crashing the CLI on the EPERM.
click.echo(
f"Skipping stale daemon record for {record.target!r}: pid "
f"{record.pid} is owned by another user and is not this daemon.",
err=True,
)
return True
return False
def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
"""
Terminate one local daemon process.
@@ -9024,8 +9373,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
if not _pid_alive(record.pid):
_delete_daemon_record(record)
return
with contextlib.suppress(ProcessLookupError):
os.kill(record.pid, signal.SIGTERM)
if _signal_daemon_pid(record, signal.SIGTERM):
_delete_daemon_record(record)
return
deadline = time.monotonic() + _HOST_DAEMON_STOP_GRACE_S
while time.monotonic() < deadline:
if not _pid_alive(record.pid):
@@ -9033,8 +9383,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
return
time.sleep(0.1)
if force:
with contextlib.suppress(ProcessLookupError):
os.kill(record.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
if _signal_daemon_pid(record, getattr(signal, "SIGKILL", signal.SIGTERM)):
_delete_daemon_record(record)
return
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if not _pid_alive(record.pid):
@@ -10717,6 +11068,12 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
the workspace rejects it).
:raises click.ClickException: When the Databricks CLI binary is
missing or the login exits non-zero.
The login writes to a ``.databrickscfg`` profile named after the
workspace's first DNS label (e.g. ``acme`` for
``acme.cloud.databricks.com``), keeping distinct workspaces off the
shared ``DEFAULT`` profile. The OAuth grant itself stays host-keyed,
so :func:`_databricks_workspace_token` still resolves it by host.
"""
databricks_bin = shutil.which("databricks")
if databricks_bin is None:
@@ -10725,14 +11082,21 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
)
login_host = _host_with_org(workspace_host, org_id)
click.echo(f"Opening browser to log in to {login_host} ...")
# Pin the grant to a profile named for the workspace's first DNS label so
# distinct workspaces don't clobber each other under the CLI's ``DEFAULT``.
from urllib.parse import urlsplit
split = urlsplit(workspace_host.rstrip("/"))
host = split.hostname or split.netloc or split.path
profile = host.split(".")[0]
click.echo(f"Opening browser to log in to {login_host} (profile {profile}) ...")
result = subprocess.run(
[databricks_bin, "auth", "login", "--host", login_host],
[databricks_bin, "auth", "login", "--host", login_host, "--profile", profile],
check=False,
)
if result.returncode != 0:
raise click.ClickException(
f"`databricks auth login --host {login_host}` failed "
f"`databricks auth login --host {login_host} --profile {profile}` failed "
f"(exit {result.returncode}). If the workspace is unreachable from "
"this machine (VPN / IP access lists), resolve that and retry."
)
@@ -10785,7 +11149,7 @@ def _databricks_workspace_token(workspace_host: str) -> str | None:
try:
auth, _host = _resolve_databricks_auth(host=workspace_host)
return auth.current_token()
except (DatabricksAuthError, ValueError):
except (DatabricksAuthError, ImportError, ValueError):
return None
@@ -10955,6 +11319,9 @@ def login(server_url: str) -> None:
token=token,
user_id=user_id,
expires_at=_time.time() + expires_in,
# Login-issued refresh grant (newer servers) — lets the
# host/CLI renew past session expiry unattended.
refresh_token=result.get("refresh_token"),
)
click.echo(f"Logged in as {user_id}")
_remember_default_server(server)
@@ -11034,6 +11401,7 @@ def _accounts_login(server: str) -> None:
token=token,
user_id=user_id,
expires_at=_time.time() + expires_in,
refresh_token=body.get("refresh_token"),
)
click.echo(f"Logged in as {user_id}.")
+432 -14
View File
@@ -21,22 +21,38 @@ from __future__ import annotations
import contextlib
import json
import logging
import math
import os
import stat
import tempfile
import time
import urllib.parse
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import httpx
_logger = logging.getLogger(__name__)
_TOKEN_FILE_NAME = "auth_tokens.json"
# Treat a stored token with less than this much life left as needing
# renewal. Shared by the "is it still usable" read path and the refresh
# path's already-renewed check, so a caller that decides to refresh is
# never handed back the same near-expiry token it wanted to replace.
# Comfortably longer than a WebSocket handshake, far shorter than the
# server's 1-hour access-token TTL.
REFRESH_MIN_REMAINING_SECONDS = 90.0
def _token_file_path() -> Path:
"""Return the path to the auth token storage file.
Uses the shared ``~/.omnigent`` state directory.
Uses the shared Omnigent state directory, honoring
``OMNIGENT_DATA_DIR``.
:returns: Path to ``~/.omnigent/auth_tokens.json``.
:returns: Path to ``<data-dir>/auth_tokens.json``.
"""
from omnigent_ui_sdk.terminal._config import state_dir
@@ -128,6 +144,10 @@ def _store_entry(server_url: str, entry: dict[str, str | float]) -> None:
except (json.JSONDecodeError, OSError):
data = {}
# Corrupt token files (non-dict JSON) read as empty — never crash.
if not isinstance(data, dict):
data = {}
data[_normalize_server_url(server_url)] = entry
_write_tokens_file(path, data)
@@ -138,6 +158,7 @@ def store_token(
token: str,
user_id: str,
expires_at: float,
refresh_token: str | None = None,
) -> None:
"""Persist a session token for a server.
@@ -147,15 +168,19 @@ def store_token(
:param user_id: The authenticated user's email, e.g.
``"alice@example.com"``.
:param expires_at: Unix timestamp when the token expires.
:param refresh_token: Login-issued refresh grant token, when the
server handed one out. Lets :func:`refresh_stored_token` renew
the access token past expiry without a human re-running
``omnigent login``.
"""
_store_entry(
server_url,
{
"token": token,
"user_id": user_id,
"expires_at": expires_at,
},
)
entry: dict[str, str | float] = {
"token": token,
"user_id": user_id,
"expires_at": expires_at,
}
if refresh_token is not None:
entry["refresh_token"] = refresh_token
_store_entry(server_url, entry)
def store_databricks_auth(
@@ -211,11 +236,15 @@ def _load_entry(server_url: str) -> dict[str, str | float] | None:
except (json.JSONDecodeError, OSError):
return None
# A token file holding valid JSON of the wrong shape (``[]``, ``null``,
# a bare string) is corrupt, not fatal — read as "nothing stored".
if not isinstance(data, dict):
return None
entry = data.get(_normalize_server_url(server_url))
return entry if isinstance(entry, dict) else None
def load_token(server_url: str) -> str | None:
def load_token(server_url: str, *, min_remaining_seconds: float = 0.0) -> str | None:
"""Load a stored session token for a server.
Returns ``None`` if no token is stored, the token has expired,
@@ -225,6 +254,11 @@ def load_token(server_url: str) -> str | None:
:param server_url: The server URL, e.g.
``"http://localhost:6767"``.
:param min_remaining_seconds: Require at least this much remaining
lifetime. ``0`` (the default) accepts any not-yet-expired token
the historical behaviour. A caller that can renew passes
:data:`REFRESH_MIN_REMAINING_SECONDS` so a token about to lapse
mid-handshake is refreshed instead of used.
:returns: The session JWT string, or ``None``.
"""
entry = _load_entry(server_url)
@@ -233,13 +267,238 @@ def load_token(server_url: str) -> str | None:
expires_at = entry.get("expires_at", 0)
if isinstance(expires_at, (int, float)) and expires_at < time.time():
_logger.debug("Stored token for %s has expired", _normalize_server_url(server_url))
_warn_expired_once(server_url, expires_at, has_refresh="refresh_token" in entry)
return None
# Near-expiry but still valid: decline quietly (no expiry warning — it
# has not expired) so the caller can choose to renew.
if (
min_remaining_seconds > 0
and isinstance(expires_at, (int, float))
and expires_at - time.time() < min_remaining_seconds
):
return None
token = entry.get("token")
return token if isinstance(token, str) else None
# Servers already warned about an expired stored token, so a poll/retry
# loop doesn't repeat the warning every few seconds.
_warned_expired_servers: set[str] = set()
def _warn_expired_once(server_url: str, expires_at: float, *, has_refresh: bool) -> None:
"""Warn (once per process per server) that a stored token expired.
Expiry used to be a DEBUG line, which left the host dialing
unauthenticated into a misleading 403 with no breadcrumb an
operator's first actionable signal must name the cause and the
remedy.
"""
normalized = _normalize_server_url(server_url)
if normalized in _warned_expired_servers:
return
_warned_expired_servers.add(normalized)
expired_on = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(expires_at))
if has_refresh:
_logger.warning(
"Stored login session for %s expired on %s; refresh will be "
"attempted on the next command if possible",
normalized,
expired_on,
)
else:
_logger.warning(
"Stored login session for %s expired on %s and holds no refresh "
"material. Run `omnigent login %s` to re-authenticate.",
normalized,
expired_on,
normalized,
)
def stored_token_status(server_url: str) -> str:
"""Classify the stored auth state for a server.
Lets callers distinguish "never logged in" from "logged in but the
session lapsed" — the difference between proceeding unauthenticated
(header-mode servers accept that) and surfacing an actionable
re-login message.
:param server_url: The server URL.
:returns: ``"ok"`` (valid token stored), ``"expired"`` (entry exists
but the token lapsed), or ``"absent"`` (no token entry at all;
includes Databricks pointer records, which hold no token).
"""
entry = _load_entry(server_url)
if entry is None or not isinstance(entry.get("token"), str):
return "absent"
expires_at = entry.get("expires_at", 0)
if isinstance(expires_at, (int, float)) and expires_at < time.time():
return "expired"
return "ok"
@contextlib.contextmanager
def _token_file_lock() -> Iterator[None]:
"""Exclusive advisory lock over token-file read-modify-write cycles.
Serializes concurrent refreshes on one machine (host + CLI sharing
``auth_tokens.json``) so only one performs the network exchange and
the other picks up its result. Best-effort on platforms without
``fcntl`` (Windows): the refresh still works, only the local
serialization is lost.
:raises OSError: If the lock file cannot be created or opened the
caller degrades to "cannot refresh" (a state directory we cannot
write is one we could not persist the result to either).
"""
lock_path = _token_file_path().with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
try:
import fcntl
except ImportError:
yield
return
with open(lock_path, "w") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
def refresh_stored_token(server_url: str, *, timeout: float = 10.0) -> str | None:
"""Renew the stored access token from its login-issued refresh grant.
POSTs ``grant_type=refresh_token`` to the server's ``/oauth/token``,
persists the result, and returns the fresh access token. Safe to call
opportunistically: returns ``None`` when there is nothing to refresh
(no entry / no refresh material) or when the server refuses (grant
revoked, past its absolute lifetime, or an older server without the
endpoint).
Runs under the token-file lock and re-checks state after acquiring
it, so of two concurrent callers only one performs the network
refresh and the other returns the already-renewed token.
:param server_url: The server URL, e.g. ``"http://localhost:6767"``.
:param timeout: HTTP timeout in seconds.
:returns: A valid access token, or ``None``.
"""
normalized = _normalize_server_url(server_url)
# Cheap pre-check BEFORE touching the lock file: with no refresh
# material there is nothing to do, and creating a lock file would
# raise on a read-only state directory — masking the caller's other
# credential sources (e.g. the Databricks SDK fallback).
pre = _load_entry(server_url)
if pre is None or not isinstance(pre.get("refresh_token"), str) or not pre["refresh_token"]:
return None
try:
with _token_file_lock():
return _refresh_locked(server_url, normalized, timeout)
except OSError as exc:
# Cannot lock/persist (read-only or full state dir) — a refresh we
# could not store is worse than none, so decline and let the caller
# fall through to its other credential sources.
_logger.debug("Token refresh for %s skipped, state dir unusable: %s", normalized, exc)
return None
def _refresh_locked(server_url: str, normalized: str, timeout: float) -> str | None:
"""Perform the refresh exchange; caller holds the token-file lock."""
entry = _load_entry(server_url)
if entry is None:
return None
# Another process may have refreshed while we waited on the lock. A
# freshly minted token is far from expiry, so this cleanly separates
# "someone already renewed" from "this is the same near-expiry token".
expires_at = entry.get("expires_at", 0)
token = entry.get("token")
if (
isinstance(token, str)
and isinstance(expires_at, (int, float))
and expires_at - time.time() > REFRESH_MIN_REMAINING_SECONDS
):
return token
refresh_token = entry.get("refresh_token")
if not isinstance(refresh_token, str) or not refresh_token:
return None
import httpx
try:
resp = httpx.post(
f"{normalized}/oauth/token",
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
timeout=timeout,
)
except httpx.HTTPError as exc:
_logger.warning("Token refresh against %s failed: %s", normalized, exc)
return None
if resp.status_code != 200:
_logger.warning(
"Token refresh against %s refused (HTTP %d) — run `omnigent login %s` "
"to re-authenticate.",
normalized,
resp.status_code,
normalized,
)
return None
try:
payload = resp.json()
except ValueError:
_logger.warning("Token refresh against %s returned a malformed response", normalized)
return None
if not isinstance(payload, dict):
_logger.warning("Token refresh against %s returned a malformed response", normalized)
return None
access_token = payload.get("access_token")
new_refresh = payload.get("refresh_token")
# Only overwrite the stored pair with genuinely usable material —
# a null/non-string field must never clobber a working credential.
if not isinstance(access_token, str) or not access_token:
_logger.warning("Token refresh against %s returned no access token", normalized)
return None
if not isinstance(new_refresh, str) or not new_refresh:
# A server that renews without returning refresh material keeps the
# one we already hold (login grants deliberately do not rotate).
new_refresh = refresh_token
expires_in = _coerce_expires_in(payload.get("expires_in"))
user_id = entry.get("user_id")
store_token(
server_url,
token=access_token,
user_id=user_id if isinstance(user_id, str) else "",
expires_at=time.time() + expires_in,
refresh_token=new_refresh,
)
# A fresh token means any earlier expiry warning is stale; allow
# a new one if this credential ever lapses again.
_warned_expired_servers.discard(normalized)
_logger.info("Refreshed login session for %s", normalized)
return access_token
def _coerce_expires_in(raw: object) -> float:
"""Return a sane access-token lifetime in seconds from *raw*.
Falls back to one hour for anything missing, non-numeric, or
non-finite ``float("NaN")``/``float("Infinity")`` parse happily but
would yield an expiry that never compares as expired, pinning a dead
token forever.
"""
default = 3600.0
try:
value = float(raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
if not math.isfinite(value) or value <= 0:
return default
return value
def load_databricks_workspace_host(server_url: str) -> str | None:
"""Load the workspace host from a Databricks Apps pointer record.
@@ -277,6 +536,34 @@ def load_databricks_org_id(server_url: str) -> str | None:
# workspace request by this header (equivalently to the ``?o=`` query param).
DATABRICKS_ORG_ID_HEADER = "X-Databricks-Org-Id"
# Replica-routing header for a host-sharded deployment. The sharding layer
# routes requests by this value — else the default fallback — so a host's
# control tunnel, its runners' tunnels, and their session traffic all land on
# one replica when they carry the same key (the host_id). Omitted for an
# unsharded / single-replica deployment, which needs no sticky routing.
OMNIGENT_SLICE_KEY_HEADER = "X-Databricks-Omnigent-Slice-Key"
# A host-sharded deployment mounts the API at this path; an unsharded /
# single-replica server mounts elsewhere (usually the root). This is the
# routing-relevant shape of a server URL, so it lives here next to the
# request-header builder that keys off it (rather than in the browser-link
# helpers, which only borrow it).
WORKSPACE_API_PATH = "/api/2.0/omnigent"
def is_workspace_hosted_url(base_url: str) -> bool:
"""Whether *base_url* is a host-sharded deployment mount.
True for the host-sharded mount (``https://<host>/api/2.0/omnigent``), which
is the only deployment fronted by the sharding layer. Used to gate behavior
that only applies there (see :func:`databricks_request_headers`).
:param base_url: Omnigent server base URL, e.g.
``"https://example.databricks.com/api/2.0/omnigent"``.
:returns: ``True`` when the URL path is the workspace API mount.
"""
return urllib.parse.urlsplit(base_url.rstrip("/")).path == WORKSPACE_API_PATH
# Opaque extra request headers for dev/test: a JSON object of header name→value
# in :data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR`. Databricks deployments use it to
@@ -310,7 +597,10 @@ def _databricks_extra_headers() -> dict[str, str]:
def databricks_request_headers(
server_url: str, *, bearer_token: str | None = None
server_url: str,
*,
bearer_token: str | None = None,
host_id: str | None = None,
) -> dict[str, str]:
"""Build the headers for a request to a Databricks-fronted server.
@@ -335,8 +625,17 @@ def databricks_request_headers(
``"https://example.databricks.com/api/2.0/omnigent"``.
:param bearer_token: The workspace bearer token, or ``None`` when the
credential is supplied by a separate mechanism (or there is none).
:param host_id: The host a request is scoped to (its control tunnel, its
runners, and their session traffic all name it so they co-locate on one
replica). Pass it unconditionally: it is emitted (as the
:data:`OMNIGENT_SLICE_KEY_HEADER` routing header) only when *server_url*
is a host-sharded mount, since that is the only deployment with the
sharding layer that reads it. ``None`` defaults to the runner's own
host_id inside a runner process (via ``OMNIGENT_RUNNER_SLICE_KEY``) and
otherwise leaves routing to the default.
:returns: A header dict carrying ``Authorization``, ``X-Databricks-Org-Id``,
and/or the configured extra headers as available, possibly empty.
``X-Databricks-Omnigent-Slice-Key``, and/or the configured extra headers
as available, possibly empty.
"""
headers: dict[str, str] = {}
if bearer_token:
@@ -344,12 +643,131 @@ def databricks_request_headers(
org_id = load_databricks_org_id(server_url)
if org_id:
headers[DATABRICKS_ORG_ID_HEADER] = org_id
# Resolve the slice-key host_id when the caller names none, so every
# request still carries a key (routed by the sharding layer rather than
# depending on its default). Two ordered fallbacks, both host_ids:
# 1. In a runner process, the runner's own host_id (exported at launch as
# OMNIGENT_RUNNER_SLICE_KEY) — keys the runner's server traffic
# (transcript posts, uploads, policy checks) onto its host's replica,
# co-located with its tunnel.
# 2. Otherwise, on the CLI, this machine's OWN host_id if it already has a
# host identity — a host-less CLI request (session list, /me-adjacent
# reads, export) then keys to the replica holding this CLI's own hosts'
# tunnels. Read-only (never mints an identity), so a non-host machine
# stays unkeyed (→ default). Gated on the host-sharded mount below so
# the file read only happens for requests that could use it.
# Only a host-sharded deployment runs the sharding layer that reads the
# header; an unsharded server is single-replica and would just log a header
# it ignores — so gate emission (and the CLI identity lookup) on the mount.
# Callers never reason about the deployment; a new RPC routed through this
# builder is keyed automatically.
on_workspace_mount = is_workspace_hosted_url(server_url)
if host_id is None:
from omnigent.runner.identity import RUNNER_SLICE_KEY_ENV_VAR
host_id = os.environ.get(RUNNER_SLICE_KEY_ENV_VAR)
if host_id is None and on_workspace_mount:
from omnigent.host.identity import load_host_identity_if_present
identity = load_host_identity_if_present()
if identity is not None:
host_id = identity.host_id
# Kill switch: slice-key emission is ON by default; export
# ``OMNIGENT_HOST_SLICE_KEY_ENABLED=0`` to turn it off and fall back to the
# server's default (workspace-id) routing with no redeploy — a per-process
# escape hatch for a bad rollout, since this emits from sidecar-less
# processes (laptop CLI, managed sandbox host, spawned runner) that can't
# evaluate a server-side flag. Only the exact value "0" disables it; unset,
# "1", or anything else leaves emission on.
slice_key_enabled = os.environ.get("OMNIGENT_HOST_SLICE_KEY_ENABLED", "1") != "0"
if host_id and on_workspace_mount and slice_key_enabled:
headers[OMNIGENT_SLICE_KEY_HEADER] = host_id
# Opaque dev/test extra headers (request-routing selectors); no-op in prod
# (env unset).
headers.update(_databricks_extra_headers())
return headers
# Sentinel for the ``timeout`` argument of :func:`open_server_client`. ``None``
# is a meaningful httpx value ("disable timeout"), so it can't stand in for
# "unset". When the caller passes nothing we omit ``timeout`` entirely and let
# httpx apply its own default, rather than silently changing it.
_TIMEOUT_UNSET: Any = object()
def open_server_client(
server_url: str,
*,
auth: httpx.Auth | None = None,
bearer_token: str | None = None,
headers: dict[str, str] | None = None,
timeout: Any = _TIMEOUT_UNSET,
follow_redirects: bool = False,
transport: httpx.AsyncBaseTransport | None = None,
host_id: str | None = None,
) -> httpx.AsyncClient:
"""Open an :class:`httpx.AsyncClient` to an Omnigent server, keyed for routing.
The one way to open a client to the server. It folds
:func:`databricks_request_headers` in for you, so a request to a host-sharded
mount automatically carries the org-id and slice-key routing headers (and any
dev/test selectors) and a request to an unsharded server carries none.
Callers never reason about the deployment; a new server RPC opened through
this factory is routed correctly by construction, which is why it exists
rather than each site building headers by hand.
:param server_url: The server base URL, e.g.
``"https://example.databricks.com/api/2.0/omnigent"``. Both the client's
``base_url`` and the input to the routing-header builder.
:param auth: An httpx ``Auth`` when the credential is minted per request
(e.g. :class:`_RunnerDatabricksAuth`, which re-injects a fresh bearer and
the routing headers on the OAuth-redirect retry). Mutually exclusive with
*bearer_token* in practice: pass one or the other, not both.
:param bearer_token: A static workspace bearer, folded in as
``Authorization``. Leave ``None`` when *auth* supplies the credential or
there is none.
:param headers: Extra request headers (e.g. the runner ``Origin`` sentinel).
Merged *under* the routing headers, so routing always wins over a
caller-supplied collision.
:param timeout: An httpx timeout (``httpx.Timeout``, ``float``, or ``None``
to disable). Omitted by default so httpx applies its own default rather
than this factory silently overriding it.
:param follow_redirects: Passed through to httpx. Defaults to ``False``
(httpx's own default); callers relying on seeing a 3xx — e.g. an auth
flow that re-mints on the Databricks Apps OAuth login redirect keep it
``False``.
:param transport: An httpx ``AsyncBaseTransport`` to substitute for the
default network transport. Its one production-adjacent use is injecting a
test transport (e.g. ``httpx.MockTransport``); ``None`` uses the default.
:param host_id: The host a request is scoped to, forwarded to
:func:`databricks_request_headers` (which emits it as the slice-key only
on a host-sharded mount and otherwise falls back to the runner's own
host_id). Pass it unconditionally when known.
:returns: A configured :class:`httpx.AsyncClient`.
"""
import httpx
from omnigent_client._http import is_loopback_url
pinned = {
**(headers or {}),
**databricks_request_headers(server_url, bearer_token=bearer_token, host_id=host_id),
}
kwargs: dict[str, Any] = {}
if timeout is not _TIMEOUT_UNSET:
kwargs["timeout"] = timeout
if transport is not None:
kwargs["transport"] = transport
return httpx.AsyncClient(
base_url=server_url,
headers=pinned,
auth=auth,
follow_redirects=follow_redirects,
# A proxy cannot reach a loopback server, so local targets bypass it.
trust_env=not is_loopback_url(server_url),
**kwargs,
)
def clear_token(server_url: str) -> None:
"""Remove a stored token for a server.
+81
View File
@@ -2136,6 +2136,40 @@ def _launch_goose_configure() -> str | None:
return "Provider not detected yet"
def _show_acp_cli_harness(name: str) -> None:
"""Show install + sign-in instructions for one builtin ACP CLI harness.
These harnesses own their credentials (``OWN_AUTH``) and install out-of-band,
so there is nothing for Omnigent to store or run on the user's behalf — the
drill-in reports what it can and names the two commands. Read-only: it
changes no config, which is why it's a display rather than a manage loop.
:param name: The :data:`ACP_CLI_HARNESSES` row key, e.g. ``"grok"``.
"""
from omnigent._platform import resolve_cli_binary
from omnigent.acp_cli_harnesses import ACP_CLI_HARNESSES
from omnigent.onboarding.interactive import console
row = ACP_CLI_HARNESSES.get(name)
if row is None: # defensive: a stale key from a concurrent config change
return
installed = resolve_cli_binary(row.binary) is not None
console.print(f"\n [bold]{row.label}[/bold] — ACP agent (owns its own auth)")
if installed:
console.print(f" ✓ `{row.binary}` found on PATH")
else:
hint = row.install.install_hint or row.binary
console.print(
f" ○ `{row.binary}` not found. Install with:\n [bold]{hint}[/bold]"
)
if row.login_command:
console.print(f" Sign in with:\n [bold]{row.login_command}[/bold]")
if row.install.auth_hint:
console.print(f" {row.install.auth_hint}")
console.print(f" Launch with: [bold]omnigent run --harness {name}[/bold]\n")
def _manage_goose_harness() -> None:
"""Run the level-2 loop for Goose: ensure the CLI, then guide ``goose configure``.
@@ -3475,6 +3509,7 @@ def _run_configure_harnesses_interactive() -> None:
_ACP_IMPORT = "\x00acp-import-openclaw"
_ACP_ADD = "\x00acp-add"
_ACP_AGENT_PREFIX = "\x00acp-agent:"
_ACP_CLI_PREFIX = "\x00acp-cli:"
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
# Status glyph + Rich color per readiness kind: "ready" is a configured,
@@ -3752,6 +3787,50 @@ def _run_configure_harnesses_interactive() -> None:
(_GOOSE, "Goose", "Not configured", "warn", "Open to run `goose configure`."),
)
# Builtin ACP CLI harnesses (omnigent/acp_cli_harnesses.py) — vendor CLIs
# that speak ACP on stdio. Derived from the catalog so adding a row there
# surfaces it here too; without this they were addressable via
# `--harness <name>` but invisible in setup, making a shipped harness less
# discoverable than a user's own `acp:` entry.
from omnigent._platform import resolve_cli_binary
from omnigent.acp_cli_harnesses import ACP_CLI_HARNESSES
from omnigent.onboarding.acp_auth import acp_agents, shadowed_builtin_acp_rows
# Skip a row a configured `acp:` agent already claims, so the list shows
# one "Devin" (the user's, with its command) rather than two identically
# labeled rows. A config error is reported by the custom-ACP block below.
try:
_shadowed_acp_rows: frozenset[str] = shadowed_builtin_acp_rows(acp_agents(config))
except ValueError:
_shadowed_acp_rows = frozenset()
for _acp_cli_name, _acp_cli_row in sorted(ACP_CLI_HARNESSES.items()):
if _acp_cli_name in _shadowed_acp_rows:
continue
_acp_cli_key = _ACP_CLI_PREFIX + _acp_cli_name
if resolve_cli_binary(_acp_cli_row.binary) is None:
rows.append(
(
_acp_cli_key,
_acp_cli_row.label,
"Not installed",
"missing",
_install_hint(_acp_cli_row.install.install_hint or _acp_cli_row.binary),
)
)
else:
# The vendor owns its auth, so we can report the binary is present
# but not whether it is signed in.
rows.append(
(
_acp_cli_key,
_acp_cli_row.label,
"ACP · own auth",
"ready",
"Select for install and sign-in instructions.",
)
)
# Copilot — GitHub token (github-copilot-sdk extra is soft).
if copilot_github_token_configured(config) or any(
os.environ.get(v) for v in COPILOT_TOKEN_ENV_VARS
@@ -3939,6 +4018,8 @@ def _run_configure_harnesses_interactive() -> None:
_add_acp_agent()
elif isinstance(selected_target, str) and selected_target.startswith(_ACP_AGENT_PREFIX):
_manage_acp_agent(selected_target[len(_ACP_AGENT_PREFIX) :])
elif isinstance(selected_target, str) and selected_target.startswith(_ACP_CLI_PREFIX):
_show_acp_cli_harness(selected_target[len(_ACP_CLI_PREFIX) :])
elif selected_target == _HERMES:
_manage_hermes_harness()
elif selected_target == _KIRO:
+38 -32
View File
@@ -22,7 +22,6 @@ from tempfile import TemporaryDirectory
import click
import httpx
import yaml
from omnigent_client._http import is_loopback_url
from omnigent._native_resume_hint import echo_native_resume_hint
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
@@ -72,6 +71,7 @@ from omnigent.harness_availability import (
from omnigent.host.daemon_launch import (
error_text,
launch_or_reuse_daemon_runner,
open_daemon_client,
wait_for_host_online,
wait_for_runner_online,
)
@@ -713,8 +713,12 @@ def _run_with_remote_server(
from omnigent.cli import _ensure_host_daemon
from omnigent.host.identity import load_or_create_host_identity
headers = _remote_headers(server_url=base_url)
attach_auth = _server_auth(server_url=base_url)
# This machine's host id keys the WebSocket attach handshake (and its
# reconnects) to the replica holding the runner's tunnel; the CLI can set WS
# headers, so it rides the header (emitted only on a host-sharded deployment).
host_id = load_or_create_host_identity().host_id
headers = _remote_headers(server_url=base_url, host_id=host_id)
attach_auth = _server_auth(server_url=base_url, session_id=None)
try:
resolved_session_id = _resolve_session_id_for_resume(
base_url=base_url,
@@ -736,7 +740,6 @@ def _run_with_remote_server(
with runner_startup_progress(initial_message="Preparing Codex...") as progress:
_update_startup_progress(progress, "Connecting to local daemon...")
_ensure_host_daemon(base_url)
host_id = load_or_create_host_identity().host_id
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
prepared = await _prepare_codex_terminal_via_daemon(
base_url=base_url,
@@ -765,7 +768,7 @@ def _run_with_remote_server(
:returns: None.
"""
new_headers = _remote_headers(server_url=base_url)
new_headers = _remote_headers(server_url=base_url, host_id=host_id)
headers.clear()
headers.update(new_headers)
@@ -843,22 +846,21 @@ async def _prepare_codex_terminal_via_daemon(
"""
persist_args = list(codex_args)
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
timeout=timeout,
trust_env=not is_loopback_url(base_url),
) as client:
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
reattached = session_id is not None
fresh_session = session_id is None
if session_id is None:
if session_bundle is None:
raise click.ClickException("Creating a Codex session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Codex session...")
session_id = await _create_codex_session(
client,
session_bundle,
bridge_id=None,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_codex_session(
client,
session_bundle,
bridge_id=None,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Codex session...")
@@ -911,13 +913,15 @@ async def _prepare_codex_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
host_id=host_id,
session_id=session_id,
workspace=workspace,
fresh=fresh_session,
)
_update_startup_progress(startup_progress, "Waiting for runner...")
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
@@ -1019,9 +1023,10 @@ async def _post_initial_prompt(
:returns: None.
:raises click.ClickException: If Omnigent rejects the prompt.
"""
async with httpx.AsyncClient(
base_url=base_url,
trust_env=not is_loopback_url(base_url),
from omnigent.cli_auth import open_server_client
async with open_server_client(
base_url,
headers=headers,
auth=auth,
timeout=httpx.Timeout(30.0),
@@ -1070,12 +1075,9 @@ async def _prepare_codex_terminal(
:returns: Prepared terminal details.
"""
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
timeout=timeout,
trust_env=not is_loopback_url(base_url),
) as client:
from omnigent.cli_auth import open_server_client
async with open_server_client(base_url, headers=headers, timeout=timeout) as client:
bridge_id: str
thread_id: str | None = None
if session_id is None:
@@ -1198,6 +1200,7 @@ async def _prepare_codex_terminal(
socket_path=codex_ws_url,
thread_id=thread_id,
codex_home=str(codex_home),
cwd=str(Path.cwd()),
),
)
if runner_id is not None:
@@ -1404,9 +1407,10 @@ async def _initialize_fresh_terminal_thread(
raise click.ClickException("Codex event listener was not initialized.")
app_server_url = _require_codex_app_server_url(prepared)
thread_id = await _wait_for_thread_started(prepared.event_client)
async with httpx.AsyncClient(
base_url=base_url,
trust_env=not is_loopback_url(base_url),
from omnigent.cli_auth import open_server_client
async with open_server_client(
base_url,
headers=headers,
timeout=httpx.Timeout(30.0),
) as client:
@@ -1418,6 +1422,7 @@ async def _initialize_fresh_terminal_thread(
socket_path=app_server_url,
thread_id=thread_id,
codex_home=str(codex_home_for_bridge_dir(prepared.bridge_dir)),
cwd=str(Path.cwd()),
),
)
return thread_id
@@ -2712,10 +2717,11 @@ async def _close_codex_terminal(
:param terminal_id: Terminal resource id.
:returns: None.
"""
from omnigent.cli_auth import open_server_client
with contextlib.suppress(Exception):
async with httpx.AsyncClient(
base_url=base_url,
trust_env=not is_loopback_url(base_url),
async with open_server_client(
base_url,
headers=headers,
timeout=httpx.Timeout(10.0),
) as client:

Some files were not shown because too many files have changed in this diff Show More