* revert(sessions): unwind the #2150 approval/attribution stack (#3446, #3422, #3416) (#4318)
* revert(sessions): remove delegated approval authority (#3446)
Reverts the delegated approval feature from #3446, returning to
owner-only approval (the deny-by-default behavior from #3416). Owners
can no longer delegate a "can_approve" capability to shared editors;
approvals are again restricted to the session owner, while editors keep
reject/cancel.
The change is a faithful inverse of #3446 rebased on current main:
files untouched since #3446 revert byte-identical to their pre-feature
state; files later commits also modified keep those newer changes and
drop only the approval lines.
Migration handled non-destructively for deployed databases:
- The original additive migration (c4d5e6f7a8b9) is kept intact so
already-migrated databases still resolve their history.
- A new forward migration (f7a8b9c0d1e2) drops the session_permissions
.can_approve column; its downgrade re-adds it.
Also removes a dangling import of _approval_access_from_grants in
sessions/__init__.py left by the later wildcard-import refactor (#3934),
which otherwise broke server import after the helper was reverted.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* revert(sessions): remove shared-message attribution (#3422)
Reverts the model-visible shared-message authorship feature from #3422.
Messages no longer gain `[author]:` prefixes in the model prompt, the
SHARED_SESSION_AUTHORSHIP_INSTRUCTION framework instruction is removed,
and the OMNIGENT_SHARED_MESSAGE_ATTRIBUTION_ENABLED switch is gone.
Persisted `created_by` authorship (a store-level column predating #3422)
is unaffected.
Rebased on current main, keeping later independent work in the same
regions:
- Smart Routing's conditional `model_override` on the native-terminal
forward path is preserved.
- The `host_store` parameter added to the event-forward path is kept.
- The two `test_external_interrupt_*` tests from #4160 (which overlap
#3422's added block in test_sessions_endpoints.py) are kept; only
#3422's `test_external_user_message_strips_model_author_prefix` is
removed.
Also removes dangling imports of `_strip_pending_author_prefix` in
orchestration.py and sessions/__init__.py left after the helper's
definition was reverted.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* revert(sessions): restore editor approval authority (#3416)
Reverts the owner-only approval restriction from #3416. Approval events
and URL-based elicitation resolution are gated at LEVEL_EDIT again, so
shared editors — not only the owner — can resolve approvals.
SECURITY REGRESSION (intentional, per request): #3416 was a security
fix. Shared-session tools execute with the session owner's runner
identity and ambient credentials, so a shared editor can once more
authorize owner-credentialed tool calls. This, together with the #3422
and #3446 reverts, fully unwinds the #2150 stack and re-opens #2150.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
(cherry picked from commit 7efe05623b)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(web): make the header Chat/Terminal switcher a segmented toggle (#4385)
The header switcher hid both destinations behind a dropdown: a
MessagesSquare + chevron trigger you had to open before you could see
which view you were in or switch to the other one. Reading the current
view took a hover (the tooltip), and switching took two clicks.
Replace it with a two-segment icon toggle in a shared track. Both
destinations are always on screen, the active one is filled, and
switching is a single click. Sits in the same header slot, immediately
left of Share, at the same 32px scale as the neighbouring controls
(size-6 segments in a p-0.5 track).
Behavior is unchanged: the same TerminalFirstContext drives it, it
self-gates for non-terminal-first sessions, the iOS shell (native
Liquid Glass bar), and rail-opened shell views, and Terminal stays
disabled — with a spinner while a PTY is coming up — until one is
reachable. Each segment carries aria-pressed and a tooltip naming it,
so the icon-only control stays legible to pointer and AT users alike;
the Terminal tooltip doubles as the "starting up" explanation.
Collapsing the menu drops the machinery it needed: the controlled
tooltip (two merged Slots on one node dropped its listeners), the
pointer-vs-keyboard close-refocus ref, and the e2e open-retry loop
that existed because a toggle-trigger click could net back to closed.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
(cherry picked from commit 95186250cb)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(claude-native): keep MCP tool search on for gateway-backed native Claude (#4533)
The native-claude launch config unconditionally set
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 on the ucode and bedrock provider
paths. That flag disables *all* experimental betas, including MCP tool
search (which rides on the `advanced-tool-use` beta). With tool search off,
Claude Code loads every MCP tool schema eagerly, inflating the context
window — for an isaac-omni session with ~187 MCP tools that is ~88k tokens
spent up front instead of on demand.
The disable flag existed to avoid the gateway 400ing on `invalid beta flag`.
But in gateway-aware mode (CLAUDE_CODE_USE_GATEWAY=1) Claude Code negotiates
the anthropic-beta set with the gateway rather than sending every flag
blindly, and the Databricks AI Gateway now accepts the flags it sends
(verified end-to-end against a live gateway: a CLAUDE_CODE_USE_GATEWAY=1
turn sends advanced-tool-use-2025-11-20 / prompt-caching-scope-2026-01-05 /
advisor-tool-2026-03-01 and completes with no 400). So the workaround is no
longer needed when USE_GATEWAY=1.
- _provider_config_for_native_claude (generic gateway path): already
guarded on CLAUDE_CODE_USE_GATEWAY (unchanged).
- _ucode_config_for_profile: this path always launches in gateway mode
(it sets CLAUDE_CODE_USE_GATEWAY=1 itself), so drop the disable flag
outright rather than guard it. Restores the pre-#4074 behavior.
- _bedrock_config_for_native_claude: add the same USE_GATEWAY guard the
generic gateway path uses, so a bedrock-style corporate gateway running
in gateway-aware mode keeps tool search on. Real AWS Bedrock (no
USE_GATEWAY) is unchanged — the flag still gets set.
Tests: update the ucode assertion, add positive coverage for the gateway
and bedrock paths under USE_GATEWAY=1, and make the env-sensitive tests
deterministic by clearing CLAUDE_CODE_USE_GATEWAY.
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
(cherry picked from commit 55a270a2d8)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(codex): speak codex's model vocabulary on a CLI login (#4558)
Codex's own backend (ChatGPT account or API key) names models with a
dotted version, `gpt-5.6-sol`. Databricks serving names the same model
with hyphens only, `databricks-gpt-5-6-sol`. Two places sent the wrong
one, so every codex dispatch on a CLI login failed at launch with a 400.
The curated codex catalog carried the Databricks spelling, so selecting
any offered model was rejected. It now carries codex's own slugs, which
still fold to the same comparable spelling, leaving routed-arm matching
unchanged.
The launch default resolved through the generic OpenAI catalog, whose
newest row is the bare family alias `gpt-5.6` that codex rejects as a
family name. Only the Databricks-gateway branch consults that catalog
now; a codex CLI login defaults to a concrete variant from codex's own
catalog. The Databricks branch keeps its hyphenated ids.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
(cherry picked from commit 5857a2c3d6)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(host): keep CLAUDE_CODE_USE_GATEWAY / ENABLE_TOOL_SEARCH in the runner env allowlist (#4553)
A launcher (e.g. Databricks' isaac) sets CLAUDE_CODE_USE_GATEWAY=1 and
ENABLE_TOOL_SEARCH=true in its process env so the native-claude harness keeps
MCP tool search on (schemas load on demand). But the host daemon env
(`_build_host_daemon_env`) and the runner env (`_build_runner_env`) are both
built from `_RUNNER_ENV_ALLOWLIST`, and neither var was on it — so they were
stripped at daemon spawn and never reached the runner process.
The native-claude provider path (`_provider_config_for_native_claude`,
`_ucode_config_for_profile`, `_bedrock_config_for_native_claude`) reads
CLAUDE_CODE_USE_GATEWAY from os.environ to decide whether to set
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. With it stripped, the runner saw it
absent, re-added the disable flag, and Claude Code turned tool search off —
loading every MCP tool schema eagerly (~88k tokens for ~190 MCP tools at
startup instead of on demand).
Add both non-secret boolean flags to `_RUNNER_ENV_ALLOWLIST`, beside the
existing CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_SKIP_BEDROCK_AUTH flags (same
category). The single allowlist is consulted by both gates, so the vars now
survive daemon spawn and runner spawn and reach the guard.
Tests: assert both vars survive `_build_host_daemon_env` (local + remote) and
`_build_runner_env`. They fail before this change and pass after.
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
(cherry picked from commit a2de2b44ac)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): count managed-settings AIGW backing for claude-native (#4491)
* fix(routing): count managed-settings AIGW backing for claude-native
claude_gateway_inference_backed() returned False whenever
resolve_native_claude_config yielded no config — the case for a
subscription (Claude Code login) provider. But Claude Code itself still
routes all inference through an AI Gateway when an enterprise managed
settings file pins ANTHROPIC_BASE_URL, so Smart Routing was being gated
off for a genuinely gateway-backed launch. Codex already reads its own
config.toml base_url; this brings Claude to parity.
Add a fallback: read Claude Code managed settings and treat the launch as
gateway-backed when env.ANTHROPIC_BASE_URL is a Databricks AI Gateway URL
(validated with is_databricks_ai_gateway_url) and a credential is
delivered via top-level apiKeyHelper or a truthy env.CLAUDE_CODE_USE_GATEWAY.
Managed settings win at the real launch, so this signal can flip the
answer to True even when the omnigent provider is subscription.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): validate the resolve-path base URL as a Databricks AIGW
The resolve-based branch of claude_gateway_inference_backed() returned
True on just ANTHROPIC_BASE_URL + api_key_helper being present, without
checking the URL is actually a Databricks AI Gateway. A bare
api.anthropic.com (or any non-Databricks Anthropic-compatible endpoint)
would qualify — but the external task_v1 router's picks are Databricks
catalog ids that endpoint cannot serve. Require
is_databricks_ai_gateway_url() on the resolved base URL too, matching the
managed-settings fallback and the Codex check.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): resolve cli-config codex base URL from the shared config.toml
native_codex_launch_base_url() returned None for a cli-config launch,
because such a launch pins only a model_provider name — the provider
table (with base_url) lives in the user's shared ~/.codex/config.toml,
which the launch never inlines. So codex_gateway_inference_backed()
reported a genuinely AIGW-routed cli-config provider as not backed,
gating Smart Routing off. This is the Codex analogue of the Claude
managed-settings gap.
Read the shared config.toml in the final branch: extract the pinned
provider name (codex_session_meta_model_provider), locate the user's
CODEX_HOME config via _codex_home_config_source_from_env, and return
model_providers.<name>.base_url with tomllib. openai (Codex's own login)
and omnigent_databricks (the profile branch's generated id) have no
user-config table, so they stay None. Any read/parse failure returns
None — an unreadable config is unknown, not backed. codex_gateway_
inference_backed() is unchanged; it validates the URL as before.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): resolve codex config-default base URL for the empty-override launch
The prior commit covered a cli-config launch that pins a model_provider
name, but the user's Databricks-wide setup hits a different path: when no
omnigent provider resolves and the config default is not dismissed,
resolve_native_codex_launch leaves config_overrides empty on purpose so
Codex uses its own config.toml top-level model_provider default. On such
a machine that default is a Databricks AIGW provider, yet the probe saw
empty overrides and reported not-backed.
Extend native_codex_launch_base_url: when a launch pins no model_provider
override and no profile, resolve the config.toml top-level model_provider
default's base_url (unless the user dismissed the default, which pins
Codex's built-in openai). An explicit model_provider="openai" override
(subscription / dismissed paths) still returns None — only a truly
unpinned launch reads the config default. Factor the shared table lookup
into _config_toml_provider_base_url, used by both the cli-config and
config-default paths.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): count a resolvable launch base URL as codex readiness
_codex_auth_unavailable_reason() detected a provider-routed launch only
via a profile or a non-openai model_provider override. On a Databricks-
wide machine the launch pins neither — omnigent defers to Codex's own
config.toml top-level model_provider default — so readiness fell through
to the auth.json check, found no openai credential, and falsely reported
needs-auth even though bare `codex` works. That gated the Smart Routing
harness row off in New Chat (it needs both claude-native and codex-native
ready).
Broaden the predicate to also count a resolvable launch base URL
(native_codex_launch_base_url(launch) is not None), which now resolves
the config.toml provider default. This only adds a ready case: an
explicit model_provider="openai" pin still returns None from that helper,
so a genuinely logged-out openai user still reports needs-auth. Readiness
now agrees with the launch resolver and the gateway-inference check.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: wrap the codex config.toml fixture under the line limit
Split the three identical model_providers config-toml f-strings across two
adjacent literals so each line stays under 99 chars, clearing the ruff E501
that failed pre-commit.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
(cherry picked from commit ff20407a2d)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
Co-authored-by: Serena Ruan <82044803+serena-ruan@users.noreply.github.com>
Co-authored-by: Dhruv Gupta <dhruv0811@gmail.com>
Co-authored-by: Harry Yao <harryyao13@gmail.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
* fix(server): stop a runner drop from failing finished sub-agents
Sub-agents ride their parent's runner, so a tunnel drop reaches every
child bound to it. `_on_runner_disconnect` marked all of them `failed`
regardless of whether they were mid-turn, and published the edge with no
`ErrorDetail` — so an Agents rail full of sub-agents that had completed
successfully went red, with nothing recording why.
The missing cause also made the state sticky: `_publish_runner_recovered_status`
only clears a failure it can identify as a disconnect, so the fan-out's
unlabelled `failed` survived a reconnect until the next `running` edge.
Only the per-session relay wrote the cause, and a session whose stream
already ended on `[DONE]` has no relay left to write it.
Both callbacks now go through `_mark_runner_sessions_offline`, which
skips sessions that were not mid-turn (cache first, the persisted
`live_status` as fallback), skips an intentional Stop/archive teardown,
and stamps the cause on the ones it does fail. `_on_runner_exited` passes
`fail_idle_top_level=True` so a runner that died before it could run
anything still surfaces on its top-level session; an idle sub-agent is
skipped either way, since its runner was already live.
No frontend change: `subagentStatus.ts` already renders a
`runner_disconnected` / `runner_failed_to_start` cause as a quiet
"Disconnected" rather than the red "Failed" — it was never given the data.
Addresses Gap 2 of #1113.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the runner-disconnect fan-out end to end
The unit tests cover the reconciliation decision, but the wiring lives in
a `create_app` closure that cannot be imported. Drive a genuine WS close
on a dedicated runner with two sessions bound to it — one mid-turn, one
idle — and assert the idle one is untouched while the interrupted one is
failed with `runner_disconnected` labels.
Binds through the store rather than a PATCH so no relay spawns: the relay
reacts to the same close, which would leave it ambiguous which path
produced the labels.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
This PR was opened during a GitHub Actions dispatch outage (no
pull_request workflow runs were created repo-wide between 20:50Z and
22:41Z), so its opened / synchronize / ready_for_review events were all
dropped and no checks ever ran. Empty commit to fire a fresh
synchronize now that dispatch has recovered.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
Dispatch for `pull_request` workflows has been intermittent repo-wide;
this PR's earlier events landed in a dead window. Firing a fresh
synchronize while dispatch is confirmed working.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the crash-report flag against interrupted and stopped turns
Two gaps in the reconciliation matrix: a mid-turn sub-agent under
`fail_idle_top_level` (a crash report must never downgrade an
interrupted turn), and an intentionally stopped session under the same
flag (the Stop/archive skip still wins).
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): name the runner log file in "see runner logs" errors
`omnigent codex` (and its siblings) surface the runner's message verbatim, so
a failed native terminal start read:
Codex terminal ensure failed (500): Native Codex terminal failed to start;
see runner logs for details.
which left the user hunting for a file whose name they could not know. The
runner already knows its own log path — the host passes it as
OMNIGENT_PROCESS_LOG_FILE when it spawns the subprocess — so name it:
... failed to start; see the runner log for details:
~/.omnigent/logs/runner/runner-<session>-<timestamp>.log
Same treatment for the generic runner detail string (_client_safe_error_detail,
~40 call sites: harness spawn, spec resolve, model change, compact, MCP
dispatch). The client-safe contract is unchanged: the raw cause still goes to
the log only, and the path is home-relative so it points somewhere without
leaking the account name.
process_logging grows current_process_log_path() / process_log_reference() to
publish the path, and display_log_path() is promoted out of host/connect.py
(it was private there) so both sides format paths the same way. The
daemon_launch "runner did not connect" message stops hardcoding
~/.omnigent/logs/runner/ and computes the real dir, so it is correct under
OMNIGENT_DATA_DIR.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(runner): pin the runner log path instead of trusting test order
The three tests asserting the new "see the runner log for details: <path>"
messages set OMNIGENT_PROCESS_LOG_FILE and expected the message to name it.
That holds only until some earlier test in the same xdist worker runs the real
configure_process_logging: test_runner_entry's
test_main_preserves_unexpected_runtime_errors calls main() without stubbing it,
which allocates ~/.omnigent/logs/runner/runner-<timestamp>.log and publishes
that path process-wide. The published path outranks the environment (it is what
the process actually logs to), so the assertions saw the leaked path and the
runner-app group failed in CI while passing when run alone.
Pin both sources in one place: a pinned_runner_log fixture in
tests/runner/conftest.py sets the published path and the env var, so the
assertions hold whatever else the worker ran first.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The rename's optimistic cache write reaches the row as a prop from the
sidebar list above it, which re-renders a tick after the row's own
`setIsEditing(false)`. For that one frame the row repainted the
pre-rename title as the inline editor closed.
Hold the committed title in the row until the prop carries it, or until
the PATCH settles so a failed rename rolls back to the old name.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): don't fetch history when opening a session
Opening a session kept loading older history for seconds after the page had
settled, shifting the transcript under a reader who had never scrolled. On a
real session that was 15 requests and a "Loading earlier messages…" row, for
someone who hadn't touched the scrollbar.
Two things drove it. bindStream rendered one 20-item page and HistoryAutoLoader
then paged from a layout effect until it found the previous user prompt. And
the scroll rule was "scrollTop is near the top", which the open satisfies by
itself: the pane scrolls to the bottom on load, and on a transcript shorter
than the fetch threshold that lands trivially near the top — so it fetched, the
prepend moved the cursor, and that fed the next fetch.
Fetch the window in one larger request at bind, and page only when the reader
asks. "Asks" is the gesture, not the movement: a pane shorter than the window
has no scroll range, so waiting for scrollTop to fall would strand older
history behind a scroll the pane can never report. A wheel-up or a downward
touch drag arms paging whether or not the pane has anywhere to go.
Also cap the trailing spacer at a third of the viewport, so a short latest turn
no longer reserves most of the screen as blank.
Measured on a real session, sitting still: 15 items requests -> 1, 13
transcript height steps -> 1, and the loading row never appears.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(ui-snapshot): update the turn-rail baseline for the capped spacer
Capping the trailing spacer at a third of the viewport means a short latest
turn no longer pushes everything to the top, so the preceding exchange stays
on screen. Adopted from the gate's own render (update_baseline_from_pr.sh).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): fetch one window on reconnect too, and drop the dead page walk
The reconnect gap-close still grew its window with the multi-page
prompt-boundary walk, so the two paths that replace the whole transcript had
started to diverge — and its docstring's "exactly as a cold bind would" was no
longer true. That path fires off a dropped stream, so the reader didn't ask for
it either; paging it in over several requests shifts the transcript under them
for the same reason opening a session used to.
Point it at the same single window fetch. That leaves fetchInitialHistoryWindow
with no callers, so remove it along with MAX_INITIAL_PAGES / isUserPrompt /
initialWindowComplete and the tests covering it.
test_transcript_scroll_stability seeded 30 turns (60 items) to guarantee older
history beyond a 20-item window; a 100-item window swallows the whole
transcript, so its scroll-up had nothing to fetch. Seed past the new window
instead of relaxing what it asserts.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(ui-snapshot): re-render the turn-rail baseline after merging main
Main and this branch both moved this baseline, so the merge conflicted on it.
Neither side is right on its own — the correct image is a render of the merged
code (main's chat/sidebar polish plus this branch's capped spacer). Adopted
from the gate's own render of the merge commit.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix: let a healthy route finish before the routing hook gives up
The first-message ladder was sized from the routing call alone, but the
server prepares the candidate catalog before it calls the router — about
three seconds on a first message. A healthy route therefore cost ~4.8s
against a 7s relay budget that started earlier, so the runner abandoned
verdicts that did arrive: the attempt was wasted, the prompt was replayed
a second time, and the transcript showed it twice.
Each hop now covers preparation plus the call, with the hook budget at the
15s ceiling and the harness kill still under Claude Code's own 30s
UserPromptSubmit default. A wedged router costs 15s instead of the 45s it
cost before this ladder existed. The magnitude test gains a floor as well
as a ceiling, so a future tightening cannot re-open the gap.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): say claude and codex on spawn chips, without the native suffix
A spawn chip's harness id is how the spawn runs, not something the chip
needs to spell out; the native suffix reads as noise there. SDK-brain
sub-agents (a bundle agent's codex / claude-sdk children) carry no suffix
and render unchanged, as do the session's own session/turn chips.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: align the spawn-gate budget assertion with the widened ladder
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): keep a pinned session's spawns in its own family at the source
A pinned Smart Routing session was offered every agent by
``sys_agent_list``, so a codex session could stand up a claude-native
child and only then have routing decline it. Refuse the spawn before it
happens instead:
- ``sys_agent_list`` drops built-ins outside the caller's family when the
caller routes its spawns and is not auto-harness.
- ``POST /v1/sessions`` refuses an out-of-family child of such a parent,
naming the rule.
Auto-harness parents still cross families (the router owns theirs), and a
plain session sees and spawns exactly what it did before. The routing
decline stays as the fail-safe for a pane that exists anyway.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): decline a route-turn whose parent routes another family
``route_turn_hook`` routed a pane's first typed prompt in the pane's own
family with no look at its parent, so a child pane on another family's CLI
could be pinned to a model its parent's family serves and the pane cannot
speak. The policy now declines (fail-open, nothing pinned, no chip) when
the pane's parent is a pinned Smart Routing session of another family.
The create gate refuses such a pane outright, so this only catches a row
that predates it — hence non-terminal, and the parent's switch stays
togglable.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): a failed auto-harness route must not claim the route-once label
The auto-harness path stamped the routing-decision label on its own
"unavailable" card, and that label is the route-once gate — so a router
that happened to be down when the session started made every later
in-harness prompt decline as "already routed". Leave the label unclaimed
on failure, the way the turn, native-pane and child-spawn paths already
do; the declined card still says what happened.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): stop routing a Smart Routing create's prompt twice
A native Smart Routing create routes the landing screen's prompt and pins
what it picked; the harness then submits that same prompt, and the
first-prompt hook scored it again — a second judge call tens of seconds
later, for the verdict the pane was already running on, and a needless
block-and-replay of the turn.
The create now fingerprints the prompt it routed (a hash: the label is
metadata, and the user's prompt does not belong there). When the hook sees
that prompt again it claims the create's decision instead of making a new
one — one router call, one chip. A prompt the user edited before sending
does not match and still routes on its own, as does the first prompt of a
session whose create-time route failed.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* perf(routing): take catalog preparation off the turn path
A first routed message spent ~3.2s preparing routing candidates before the
routes:select POST went out, and nothing in the logs named where it went. Two
runner-derived catalogs were being resolved while the user's prompt was held:
the claude-native picker vocabulary, whose stale entry the turn path awaits for
up to _ROUTING_CATALOG_WAIT_S (3.0s) while the fetch retries a booting runner,
and the runner model catalog, a round trip per turn for every pane that has no
picker vocabulary of its own.
Warm both when the runner binds instead. _on_runner_connect now calls
prefetch_session_routing_catalogs once the session-init handshake has created
the terminal, so the catalogs land before the first prompt rather than under
it. The runner catalog also gains a per-session cache behind _fetch_runner_catalog
(single-flight, 5-minute backstop TTL) whose entries drop through the seam that
already invalidates runner-derived snapshot overlays — a rebind or relaunch can
change which models a pane accepts, so it must not keep routing off the previous
runner's list. A cold cache still takes the inline fetch, so nothing depends on
the prefetch having run.
route_turn now logs its two phases separately (prep vs router) and the stale
catalog refresh logs what it waited, so the timeout ladder can be revisited
against measurements instead of a guess. The ladder constants are unchanged
here.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(codex): check a routed slug is reachable before switching the pane
The routing verdict comes from a server-side gateway map that can go stale, so
the routed model is not necessarily one this pane's gateway serves. The hook
switched onto it regardless: codex accepted the id, the next turn failed, and
nothing anywhere said why — the failure mode the #4074 review flagged.
The pane's live model/list is the only authority on what it can be moved onto,
and the hook already reads it to translate the routed id into codex's spelling.
Make that read the reachability check too: codex_model_slug becomes
codex_reachable_model_slug and answers None when no row names the model, and
_apply_thread_model returns a decline reason instead of a bare bool. An
unreachable pick leaves the pane on its own model, writes no marker, blocks
nothing, and records "routed model not in this pane's catalog" to the routing
trace and stderr — the same fail-open shape the claude side uses when a routed
model has no spelling its picker accepts.
A model/list that cannot be read is now distinguished from an empty catalog and
also declines: an unreadable catalog is not evidence of reachability, and
declining costs a turn of routing where switching blind costs the turn itself.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(auth): one workspace identity, and a refresh that can fall back
Two credential faults that made a healthy workspace look unreachable.
**One identity.** A pane and the server could authenticate as different
~/.databrickscfg profiles for the same host. The server's router client uses
the config's `kind: databricks` provider profile; the claude-native pane
installed ucode's recorded token command, which selects the workspace however
ucode was set up — usually by host. Two profiles on one host are two
identities, so re-authing one left the other's token expired and the two halves
disagreed about whether the workspace was up. The named profile is now the
authority on both sides: the pane's apiKeyHelper is regenerated against it
(only for the recognizable `databricks auth token` shape — an enterprise
deployment's own token command has a selector we have no business guessing at),
and a `routing:` block that names no profile falls back to the provider block's
rather than to the ambient SDK chain. Host selection stays the fallback for
when nothing names a profile.
**A refresh that can fall back.** The generated helper forced a refresh on
every call. The reason is real — `--force-refresh` renews a still-valid token
and keeps a long gateway session off a mid-session 401 — but it fails outright
once the refresh token has gone stale, which turned a perfectly usable cached
access token into a hard auth failure (twice in one day). The forced attempt is
now speculative: its output is captured, its stderr dropped, and an empty
result falls back to plain `auth token`, which serves the cached token and
renews it near expiry. The fallback keeps its stderr so a genuine auth failure
is still visible.
Both harnesses generated this command separately, so the shape now has one
definition (databricks_bearer_token_command) and the claude and codex helpers
delegate to it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: align both hook-budget assertions with the widened ladder
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: keep the catalog-cache reset import-free; cover the spawn chip in e2e_ui
The autouse cache-reset fixture imported omnigent.server.smart_routing in
every teardown, which detonated inside the spec suite's import-blocker
test and taxed lanes that never load the server. A sys.modules lookup
clears the cache only where it exists. The new Playwright case pins the
shortened spawn-chip harness label the UI judge flagged.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: leave a visible declined chip when the turn hook's routing call fails
The create and dispatch paths already card a failed route; the in-harness
first-message hook failed open silently, so a router 401 looked like the
session simply ignoring Smart Routing. The hook now persists the same
unavailable card with the cause, without claiming the route-once label —
the next prompt can still route. Benign allows (already routed, routing
off, the family guard) are not failures and stay chipless.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(cli): drop create-time Smart Routing; keep first-message routing
The CLI can only route a prompt it never shows: `--smart-routing -p` picked a
model (and, on `run`, a harness) before the TUI existed, so the user typed at a
session whose pick they could neither see nor change. The web UI is the surface
that can do that. So the CLI keeps the one routing shape a terminal can honour
— arm the session, let the harness's own hook route the first message typed —
and rejects the rest.
`omni claude|codex --smart-routing` stay, bare only. `-p` alongside them is now
a usage error pointing at the TUI or the web UI, and `run --smart-routing`
(with it the CLI's auto-harness route) is rejected outright; its flag stays
hidden purely to say where routing moved, and comes out in 0.11.
That leaves nothing behind the create-time path: the routed create no longer
sends a message or the `auto` sentinel, reads back no verdict, and the
launch-side plumbing that applied one is gone. `create_smart_routing_session`
becomes `arm_smart_routing_session` and `RoutingDecision` becomes
`ArmedSession` (session id + fail-open notice), because neither decides
anything any more. The preflight gate, the `--resume` rejection and every
server-side create path are untouched.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): drop "-native" from every routing chip, not just spawn chips
A session-scope chip read "codex-native", which leaks how the pane runs
into a label that only needs to name the brain. The shortening was scoped
to sub-agent decisions; it belongs on every chip, so harnessDisplayLabel
no longer takes a scope and always trims the trailing suffix. SDK ids
(codex / claude-sdk / auto) carry no suffix and render unchanged.
The e2e session-chip assertion now also pins the negative: a bare
"claude" substring-matches "claude-native", so only not_to_contain_text
catches a regression. Same for the card unit test, which anchors on the
full label.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): render an auto-harness create chip below its prompt
A session created with Smart Routing as both the model AND the harness
records the pick as a `session` chip at create time, and its first turn
routes again and records a `turn` chip — so two chips sit above the
session's first user message. `deferredRoutingChips` only paired a chip
whose immediate next content block was that message, so the first of the
two was left in place and rendered ABOVE the prompt, reading as a
preamble instead of the verdict on it. It only looked right when the two
verdicts matched and the create chip was dropped by the collapse.
Look forward past the sibling chips waiting on the same message (and
past superseded ones, which render nothing) and defer them all below the
message, in transcript order. A sub-agent chip still stops the scan: it
renders standalone where it occurred, and stepping over it would reorder
the two. The cache's pending-pair guard learns the same rule so the pair
stays stable frame by frame.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* perf(runner): skip the sys_agent_list routing lookup on plain sessions
Family confinement made every sys_agent_list pay a serial
GET /v1/sessions/{id} with a 30s budget before discovering the session
was not routed at all. Plain sessions — the overwhelming majority —
carried seconds of fan-out latency for a feature they never use, and a
wedged server stalled the listing for the full 30s.
Read the runner-local routing class first: a session with no routing
armed, or an auto-harness one, answers without a server hop. Only a
locally pinned routed session spends the lookup, now on a 5s budget that
fails open to the unfiltered listing, and its answer is cached for the
session (routing state is fixed at create). The create-path gate still
refuses out-of-family creates, so a fail-open listing stays safe.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(auth): fall back to ucode's recorded token command
Pinning the pane's apiKeyHelper to the config-named Databricks profile
fixed one outage and opened its mirror image: when the named profile
holds no usable credential — a config naming DEFAULT while the user
authenticated under another profile on the same host — the helper now
prints nothing and every turn 401s, where before the rewrite ucode's own
recorded command served a working token.
The named profile stays the preferred identity; the recorded command
becomes the helper's last resort, after the forced refresh and the cached
token have both come up empty. An injected DATABRICKS_BEARER still
short-circuits everything.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* perf(routing): only warm catalogs for routed, live sessions
A runner reconnect walks every session bound to that runner, and the
catalog prefetch fired for all of them — archived rows included — with no
Smart Routing gate. One host's tunnel flap with ~25 plain codex panes
launched 50 fire-and-forget tasks whose provider listings run on worker
threads, so the session re-init running alongside them timed out and the
panes came back stranded, all to warm a cache only Smart Routing reads.
Gate the prefetch on the canonical routing reader
(routing_class_from_snapshot), skip archived sessions, cap concurrent
warm-ups with a small semaphore, and have each task retrieve its own
exception: a tunnel dropped mid-prefetch raised RuntimeError that nothing
ever retrieved, which surfaced only as asyncio unretrieved-exception
noise.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(routing): route a pinned native create before its pane launches
Picking Claude Code or Codex with Smart Routing as the model created the
session with no prompt to route on, so routing fell through to the
in-pane first-message hook: the prompt was blocked, routed, switched with
`/model` and replayed. The user watched their own message disappear for
seconds, and the composer's model pill stayed stale because the pin
landed mid-turn instead of before the snapshot bound.
The web create now sends `smart_routing_message` for a pinned
claude-native / codex-native pane too, whenever routing owns the model.
The server already routes the MODEL only on that path and pins
`model_override` before the terminal launches; the client still delivers
the real first message after navigation, exactly as the auto path does.
Bundle agents are untouched — their harness isn't decided until the first
message event, so there is nothing to route at create.
With the model pinned and the routing-decision label stamped before the
pane exists, the `UserPromptSubmit` turn-routing hook has no answer left
but "already routed" — paid for with a held prompt and a round trip per
prompt. The session's routing class now carries a `turn_routing` flag
that drops to false once the row has a routing decision, and the native
launch skips the loopback router; the absent advertisement is what leaves
the hook out of the generated settings. A create whose routing failed
stamps nothing and keeps its hook, so the first message is still its
retry, and spawn routing plus the extended catalog are untouched.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): keep a create-time routing chip below the prompt it decides
A pinned Smart Routing create routes at create time, so the session-scope
decision is persisted before the pane launches while the landing composer's
prompt is only posted after navigation. The prompt is on screen the whole
time, but as an optimistic `pendingUserMessages` entry merged in AFTER the
bubble walk — never a `user_message` block — so `pairableMessageAfter` cannot
see it and the chip renders above the message until the server persists it,
then visibly moves below.
Splice the pending prompt above a run of session-scope chips that opens the
committed timeline, matching the position `buildBubbles` gives the chip once
the message is persisted. The chip renders once, below the prompt, and stays
put across the pending → committed swap. Chips anywhere else (paired with
their message, or a standalone sub-agent spawn) keep their place, and a chip
with no message — including a declined create route — still renders.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* chore: trigger CI on the rebased tip
The rebase onto main and the chip-ordering fix never ran the test lanes;
only CodeQL and DCO reported.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
## Related issue
Closes OMNI-2485 — https://linear.app/omnigent/issue/OMNI-2485
## Summary
- The Android shell previously sent *every* login through the system browser:
it stopped any off-origin navigation, requested a CLI-style ticket, opened
the browser, polled for the session JWT, then injected it as a cookie
(`OidcLoginManager`). That detour exists only because Google's OAuth endpoint
rejects embedded webviews — the browser and WebView have separate cookie
jars, so the session has to be carried across by hand.
- Databricks-hosted deployments authenticate via Okta, which permits embedded
user-agents. For those servers the whole detour is unnecessary: the redirect
chain can run inline and the server sets the session cookie on its own
domain, so nothing needs bridging.
- Adds `usesInWebViewAuth()` in `Origins.kt`, keyed on the **pinned server**
(`databricks.com`, `azuredatabricks.net`, `databricksapps.com`). When it
matches, off-origin navigation loads inline instead of triggering the browser
hop. `OidcLoginManager` is untouched and still handles every other server.
ELI5: the app used to kick you out to Chrome to log in, then smuggle the
resulting session back in. On Databricks servers it no longer needs to — you
just log in where you already are.
Keying on the pinned server rather than the destination is deliberate: during
login the WebView navigates to `databricks.okta.com`, so a destination
allowlist would have to enumerate IdP domains it can't know up front.
```mermaid
flowchart LR
A[off-origin nav] --> B{pinned server uses<br/>in-WebView auth}
B -- no --> C{gesture}
C -- yes --> D[system browser]
C -- no --> E[browser hop:<br/>ticket, poll, inject cookie]
B -- yes --> F{gesture AND<br/>on a pinned-origin page}
F -- yes --> D
F -- no --> G[load inline]
```
The gesture check is qualified by "on a pinned-origin page" because once the
WebView is on the IdP's own pages, its sign-in buttons and form posts are both
off-origin *and* gesture-driven — without that qualifier they get mistaken for
external links and ejected to the browser mid-login.
Safe because the native bridge is origin-allowlisted to the pinned origin by
WebView itself (`addWebMessageListener` / `addDocumentStartJavaScript` are both
passed `setOf(origin)`), so an IdP page loaded in this WebView cannot reach it.
Host matching uses a dot boundary (`host == d || host.endsWith(".$d")`) so a
lookalike like `databricks.com.example.org` does not qualify.
## Test Plan
- `./gradlew :app:compileDebugKotlin :app:compileDebugUnitTestKotlin` — clean.
- `pre-commit run --files <changed>` — ktlint format + check pass.
- New unit tests: 6 cases in `OmnigentWebViewClientTest` (inline IdP redirect,
browser hop for other servers, external link from the app page, sign-in tap
on the IdP page, both `onPageStarted` branches) and `OriginsInWebViewAuthTest`
for the dot-boundary matching.
- On-device against `https://omnigents-<id>.aws.databricksapps.com`: login
completes entirely in-app through Okta (Okta Verify), no browser launch and
no "Signed in" notification. `adb logcat -s OmnigentAuth`:
```
off-origin nav https://ai-oss-...cloud.databricks.com gesture=false
off-origin nav https://ai-oss-...cloud.databricks.com gesture=false
off-origin nav https://ai-oss-...cloud.databricks.com gesture=true
off-origin nav https://databricks.okta.com gesture=false
off-origin nav https://databricks.okta.com gesture=false
off-origin nav https://ai-oss-...cloud.databricks.com gesture=false
```
Every hop loads inline and `onLoginRequired` never fires. The return to the
pinned origin logs nothing because same-origin loads short-circuit earlier.
## Demo
N/A — no visual change; the difference is the absence of a browser launch. The
logcat trace above shows the new behaviour.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Unit tests could not be executed locally: Robolectric cannot fetch
`org.robolectric:android-all-instrumented` because `repo1.maven.org` is
unreachable from this machine. This is pre-existing and environmental —
untouched tests such as `ThemeTest` fail identically. Compilation of both main
and test sources was verified instead, so CI is the first real run of the new
tests. The end-to-end flow was verified on-device as described above.
Known gaps, both pre-existing and out of scope here:
- Passkey sign-in at the IdP will still fail in the WebView. WebAuthn is off by
default (`WEB_AUTHENTICATION_SUPPORT_NONE`) and enabling it needs Digital
Asset Links published at the RP ID (`databricks.okta.com`), a domain this
repo does not control. Okta Verify and password+MFA are unaffected.
- `shouldOverrideUrlLoading` hands non-http schemes to `Intent(ACTION_VIEW,
url)`, which is wrong for `intent://…#Intent;…;end` URLs (needs
`Intent.parseUri`) and fails silently under `runCatching`.
## Changelog
Signing in to Databricks-hosted deployments on Android now happens in the app
instead of bouncing out to the browser
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
* Refine conversation turn rail navigation
Use a single reading-position marker and tighter spacing so the rail is easier to scan and accurately reflects the active turn.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* Refine message hover actions
Use compact, consistently muted controls and tighter spacing so chat actions match the rest of the interface.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* Polish new-session and sidebar UX
Align composer geometry, typography, controls, host context, and project navigation so new-session flows feel consistent and clearly scoped.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* Align selection and compact action styling
Match text selection to active navigation colors and improve compact chat actions with larger glyphs and clearer spacing.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* test(e2e-ui): regenerate visual baselines
* Fix local host label test expectations
Select hosts by stable identity and accept OS-aware local labels so unit and E2E coverage matches the intended UI behavior.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
---------
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* fix(hermes-native): advance the mirror cursor per row, not per item
One Hermes `messages` row expands to several mirror items sharing a
`msg_id` (a reasoning delta, the prose, one `function_call` per tool
call), but the forwarder advanced and persisted `last_id = action.msg_id`
after each item. When an earlier item of a row delivered and a later one's
POST failed, the cursor had already moved past the row, so the next poll's
`WHERE id > last_id` skipped it and the undelivered items were lost
permanently: a silent, unrecoverable drop of an assistant turn's tool call
or prose on any transient post failure mid-row.
Advance `last_id` only at a row boundary, marked by the new
`_TurnAction.last_of_row`. A row that fails partway records
`partial_row_id` / `partial_row_items`, and the retry re-reads that row
with its already-delivered prefix dropped. The prefix-drop is required,
not defensive: `_post_conversation_item` carries no idempotency key, so
re-reading the row without it would mirror the delivered items twice.
The partial row is named explicitly rather than implied as "the row after
`last_id`", because compaction soft-deletes rows and an implied offset
could be applied to the wrong row after the row it describes disappears.
The per-poll heartbeat write and the compaction re-pin both carry or clear
the new fields, so a later poll cannot silently zero them.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(hermes-native): restart the in-row item count on a new row
The in-row delivered count was only zeroed when a row reached its final
item. A row that fails partway can disappear before its retry: compaction
soft-deletes it, and the child re-pin that resets these fields is skipped
when the session has no child (the code logs "staying on parent"). The
stale count then carried into the next row, so that row's retry dropped
undelivered items as already delivered, losing them permanently: the same
silent loss this cursor exists to prevent.
Count from 1 whenever the row is not the one already in progress. Also
pass the partial fields explicitly at the child re-pin write (the one
write site of four relying on dataclass defaults) so a future default
change cannot silently break it.
Found by Polly review on #4261.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): name the vendor, not the Task type, on native sub-agents
A Claude Code sub-agent session read "General-purpose" in the composer
identity slot and "claude-native-ui" in the header breadcrumb. Both are
internals the user should never see: the child row reuses its parent's
`<vendor>-native-ui` agent and stores Claude's own `subagent_type` as
`sub_agent_name`.
The identity paths never consulted the one label that names the product.
`modelPickerKindForConv` matches only `claude-code-native-ui`, so a
`-subagent` child fell through `composerHarnessLabel` to the agent-name
branch; `ChatHeader` rendered `boundAgent.name` raw. Resolve the vendor
from the sub-agent wrapper label instead, so both surfaces read
"Claude Code" (and "Codex" / "OpenCode"), matching the Agents rail.
The sub-agent wrapper map is kept separate from `BY_WRAPPER` so
`isNativeWrapper` still reports false for children — they own no PTY and
take no input.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the native sub-agent identity labels
The `E2E UI Required` gate gives a web/** change a required e2e_ui test.
Register a child through the real `external_subagent_start` contract the
claude-native forwarder uses, so it carries the wrapper label and the
`general-purpose` sub-agent name the identity labels must choose
between, then assert the header and composer read "Claude Code" and that
neither internal reaches the screen.
Verified it fails without the fix: with both branches disabled and the
SPA rebuilt, the "Claude Code" breadcrumb is not found.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: retrigger CI after the GitHub Actions outage
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(web): compute the sub-agent name only for child sessions
Review note: `subAgentName` ran on every render although only the
child-session branch reads it. Gate it on `isChildSession` so non-child
sessions skip the lookup.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): stop background shells from gating the composer and sidebar
When Claude Code's Stop hook fires with background shells still running, the
forwarder relabels the turn-end `idle` to `waiting`. That relabel existed only
to keep a spinner lit, but `waiting` is read as a turn gate everywhere else:
- the sidebar row spins, so a session that takes input reads as busy;
- `waiting` keeps `_session_active_response_cache` populated while the snapshot
projects it as `running`, so opening or reloading the session reopened the
already-settled turn as "streaming" — every message then queued behind
"Steer" and never drained, because the flush refuses to run while streaming;
- the composer offers Stop instead of Send.
Sub-agents already collapsed this back to `idle` (a `waiting` edge skipped the
terminal-delivery branch and hung the orchestrator). The turn has genuinely
ended for a top-level session too, so generalize that collapse: rename
`_subagent_delivery_status` to `_background_task_delivery_status` and drop the
sub-agent gate. Normalizing at server ingress rather than in the forwarder also
covers runners that predate the change. A genuine async-park `waiting` carries
no tally and is untouched.
The background-shell tally still rides the wire and the snapshot, so the in-chat
"N background tasks still running" indicator is unchanged. The tally no longer
forces a `running` sidebar row — it only refreshes on the next Stop hook, so a
spinner keyed off it can outlive the shells it claims are running.
`_best_effort_stop` used that same sidebar rollup as its "anything to stop?"
gate, so it now checks the tally directly — archiving or deleting a session
with live background shells must still stop the runner.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI after the GitHub Actions incident dropped the PR webhook
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (GitHub Actions webhook throttling, attempt 2)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 3, runners recovered)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 4, runner success rate restored)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 5, pull_request webhooks recovering)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore: re-trigger CI (attempt 6)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(server): cover the active-response close on a background-task turn end
The composer bug's mechanism had no direct unit coverage: a `waiting`
turn-end keeps the in-flight response id, and the snapshot projects
`waiting` as `running`, so a reconnect reopened the settled turn as
streaming and queued every send behind "Steer". Assert that delivering
the turn-end as `idle` closes the response while the shell tally survives.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
GitHub's cron is best-effort: the hourly sweep actually fires every 1.5 to 2.5 hours
(measured 09:34, 11:56, 14:10, 16:38, 18:17, 20:23, 22:09, 23:56 today). A
contributor waited that long for the nudge, and just as badly, waited that long for
it to stop applying after they added the issue.
Both scripts now accept PR_NUMBER and fetch that one PR instead of the window. Only
the fetch differs: every exemption, resolution, and dedupe path below it is the same
code, so the instant route and the sweep cannot reach different verdicts.
A new pr-hygiene-live workflow runs both on pull_request_target for opened,
reopened, ready_for_review, edited, and synchronize. `edited` is the one that
matters most after the nudge exists: editing the description to add "Closes #123" is
how a contributor complies, and that should clear immediately rather than in two
hours.
The sweep stays as the safety net. It catches what events miss -- a failed run, and
sidebar issue links, which fire no webhook at all -- and it is the only route that
reaches PRs opened before this workflow existed.
Two guards on the single-PR path, since an event can name a PR the sweep would never
have selected: the EFFECTIVE_FROM floor still applies, so an event on an old PR is
not a licence to reach into the backlog, and a PR that closed between the event and
the run is left alone.
Verified against production with writes blocked: #4173 skip (already nudged), #4187
exempt (maintainer), #4178 ok (has a link), #4104 skip. Each matches the verdict the
sweep reached for the same PR.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(kubernetes): classify managed runner Pods by their agent
Stamp a managed runner Pod with `omnigent.ai/agent: <name>` when the
session is bound to a genuine built-in agent, so an admission policy can
select managed runners by agent and augment their runtime (e.g. inject a
workload-scoped credential). The anti-spoof gate is unchanged
(`session_id is None AND id == builtin_agent_id(name)`), so a user-named
session agent cannot self-classify.
- capabilities: add `classifies_runner_by_agent`, set True only on the
Kubernetes launcher. `_start_sandbox_host` threads `agent_name` into
`start_host` gated on that capability, never by probing the signature —
`start_host` is side-effecting, so a pass-then-retry risks a double
launch. The shared host-launch signature is left untouched, so
exec-model launchers that forward every keyword to `super()` keep
working.
- labels: the value is echo-or-omit — stamped only when the agent name is
already a valid label value, else dropped with a WARNING. It is never
sanitized: the value selects which credential admission injects, so a
lossy collision would cross a credential boundary. The classifier rides
the Pod only, not the launch-token Secret.
- launch: resolve the classifier inside `_run_managed_launch`, on the task
that already owns the single-flight claim. Only the winner resolves, so
no store read is wasted, the claim-to-spawn region stays free of any
await, and the create path does not read the agent store before its 201.
- reserve the `omnigent.sandbox.*` label namespace from client writes.
BREAKING: session create and patch now reject client-supplied labels
under that prefix, which were previously accepted.
- docs: document the classifier lifecycle (fork/switch-agent drop the
label; switching back does not restore it; a running Pod keeps its
launch-time label until replaced), both omit paths and where each logs,
and what the label does not do — namespace RBAC, verifying the creating
identity rather than the label alone, and a fail-closed policy shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: bdchatham <bdchatham@gmail.com>
* test(managed-hosts): establish the relaunch race instead of timing it
test_concurrent_relaunch_messages_kick_a_single_launch is flaky. It failed twice
on this branch and passed either side of both failures, with the code under test
and the test itself byte-identical between a passing and a failing run, so this
is the test rather than a regression.
The race it wants is a message reaching the tracker check while the winner's
claim is still unsettled. Both callers await asyncio.to_thread twice before that
check, and an executor hop takes an unpredictable number of event-loop turns to
deliver, so holding the winner open for five turns does not establish that
ordering. On a loaded machine the racer arrives after the claim settled, takes
the settled-entry retry branch, and kicks a second launch, which reads as the
double-launch this test exists to forbid.
That retry is intended behaviour. In production a second message arriving after
a successful relaunch is turned away by the is_online check further up, which
this test stubs False forever, so the state it was asserting on is one the real
system does not present.
Reproduced deterministically by delaying the racer 50ms inside its thread hop,
which is what a loaded runner does: three failures out of three, with the same
assert 2 == 1 CI reported.
The winner now holds its claim until the racer has demonstrably read the
tracker. That is an ordering rather than a duration, and the test now contains no
sleep, no timeout and no yield count at all — the wait is unbounded on purpose,
since any number there would be a second timing assumption and the suite's own
300s timeout is the backstop. Three reads is the whole exchange, and the count is
order-independent: whichever caller wins, the winner reads twice and the racer
once, and a broken invariant makes both read before either claims, which still
fails the assertion.
Verified in both directions. Under the same 50ms delay that broke the old test
three times out of three it now passes five out of five; twenty consecutive runs
are green; and adding an await between the tracker check and the claim still
fails it with the original assertion, so the guard is intact.
Whole file green at 218 passed including under xdist, ruff clean, and mypy
reports the same 47 pre-existing errors as on the unmodified file.
Signed-off-by: bdchatham <bdchatham@gmail.com>
---------
Signed-off-by: bdchatham <bdchatham@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner's filesystem-changes routes shelled out to git synchronously,
inline on the asyncio event loop:
- `list_filesystem_changes` (the `?view=changed` file panel) →
`list_changed_files` → `git status --porcelain --untracked-files=all`
- `read_environment_file_diff` → `get_changed_file` → `git show` / `git diff`
On a large repository a cold `git status` can take several seconds (a
million-file monorepo measures ~6s here even with the untracked cache
enabled). While that blocking subprocess runs, the runner's event loop
can't service anything else — including the server's runner-stream relay
subscription probe. When a session's first turn (or the changed-files
panel) lands inside that window, the relay misses its readiness budget and
the turn fails with a 503 `runner_unavailable` ("runner didn't come online
in time"). It presents as flaky because it only fires when the git call
overlaps the readiness window — e.g. opening the UI on `?view=changed`
while the runner is still starting up reproduces it reliably.
Offload both git-backed calls with `asyncio.to_thread`, matching the
sibling `get_baseline` call in the same route. The git walk now runs on a
worker thread and the event loop stays responsive regardless of repo size
or cache warmth. Behavior is unchanged (same results, same error
handling); the redundant per-call asyncio import in the diff route is
folded into one at the top.
Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
* fix(routing): fall back to the built-in judge when the external router cannot answer
A fully-OSS deployment configures the judge through the top-level `llm:`
block, has no `routing:` block, and keeps a `kind: databricks` provider for
inference. The bootstrap then auto-builds an external routing client pointed
at that workspace's `/ai-gateway/routing/v1`, the workspace never had the
routing API enabled, and every `routes:select` came back HTTP 404 — so the
session showed "Routing unavailable" while the judge it configured was never
asked. Smart Routing was effectively off for the whole OSS flow.
Route through both backends instead of one: `route_with_fallback` still
prefers the external router wherever it can serve (the Databricks posture is
unchanged), and asks the judge behind it when that call fails or declines.
The decision records `oss-llm`, so the chip says who answered. Every routing
surface goes through it — session/create routing, turn routing, the native
route-turn hook, and subagent spawns.
The 404 whose body says routes:select is not enabled is account-level
configuration rather than an outage, so the client latches it and skips the
request from then on; `/v1/info` stops advertising a router that can only
decline. Nothing is persisted — a restart re-probes.
Choosing BETWEEN native panes still needs the workspace router's menu, so a
judge-only deployment keeps the default pane on a top-level Smart Routing
create and routes just its model, with the reason on the chip, rather than
declining into a session with no terminal.
Fail-open is unchanged throughout: a routing failure never blocks a turn, a
spawn, or a create, and never claims the route-once label.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Isaac
* fix(web): require the external router for the native-pane Smart Routing row
On a deployment whose only smart router is the built-in OSS LLM judge, the
new-session picker still offered the top-level Smart Routing row — the one
that launches a native CLI pane with the router choosing BOTH the harness and
the model. Choosing which pane launches is the external AI-Gateway (task_v1)
router's job; the judge routes a model inside an already-chosen harness, so
that row had nothing behind it and the session would fail at launch.
Gate the row on `smart_routing_sources.external`. A judge-only server now
reports its own cause ("needs the workspace AI gateway router on this
server") instead of blaming the host's CLIs. Since the row runs on the
external router alone, the built-in judge also stops covering for an arm the
host keeps off the gateway — `not-gateway-backed` fires again there.
Two neighbouring surfaces are deliberately untouched:
- Per-harness Smart Routing (the Model row's `__smart__` sentinel, router
picks the model per turn) still takes either source, so it stays on a
judge-only deployment.
- A bundle agent's routed brain (Polly / Debby's "auto" harness override)
still takes either source too — the judge picks that harness as well as its
model — and has a test pinning it against a judge-only server.
`smart_routing_sources` is absent on an older server, and `resolveServerInfo`
already degrades that to both sources from `smart_routing_enabled`, so such a
server keeps the row exactly as it had it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): keep a named-worker spawn on its own harness
A Smart Routing parent forced EVERY child create onto the "auto" harness
sentinel, including a spawn that named a worker (polly's `pi`,
`claude_code`, `codex`). The child's first message then routed against
the whole multi-harness catalog, so a pi worker came back with a codex
verdict stamped "applied" while the runner respawned its pane from pi
onto codex mid-flight — and a native worker lost the terminal labels the
forced-auto branch skips.
A named sub-agent and an explicit spawn `harness_override` both decide
the CLI the child boots on, so neither is handed the sentinel now. The
child-routing call also reads its family off the CHILD rather than the
parent: parent-derived confinement offered a pi worker the brain's claude
family, and dropped confinement entirely under an auto brain. Candidates
are the child's own harness, so the verdict is an in-family pick or an
honest decline.
Finally, a verdict naming a harness the call never offered is dropped
rather than applied (worker-name spellings still resolve), so no routing
path can pin another family onto a pane already running.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Isaac
* fix(runner): report a routed session's real harness, not its spec's
The runner derived a session's harness from its cached spec alone, so a
session Smart Routing moved off that harness still read as the one it was
declared with. On a routed child of a bundle agent that flipped the
native-vs-SDK verdict: polly's `claude_code` / `codex` workers declare
native harnesses but ran the SDK `codex` the router picked, so the
SDK turn's stream-end skipped the completion push (it belongs to a native
path that never runs) and its status events were suppressed. The parent's
inbox only ever received the `pi` sibling — the one whose declared
harness was already non-native — and it waited on the other two forever.
The forwarded `harness_override` is recorded per session and wins over
the spec, so every nativeness check answers for the process that is
actually running.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Isaac
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
The non-closing duplicate comment ended with "Leaving it open for a
maintainer to confirm", which parks the issue in a queue nobody is
watching. The reporter is the one person who can settle it immediately:
they know whether the linked issue covers their case.
Both the `duplicate` (closure disabled) and `similar` comments now ask
the reporter to take a look and close their own issue if it matches,
with an explicit path for when it doesn't. The `similar` copy stays
softer — a loose match is a weaker basis for that ask.
Rendering the new copy surfaced a pre-existing grammar bug: the plural
branch produced "these already covers this". Replaced with a phrase that
agrees in number, plus a regression test.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* dev/resolve-agent: resolve a reproduced bug (review-or-fix) and prove it
Adds the step after repro-agent: given a pointer to a completed repro run — a
local session link or a CI run URL (--ci-link) — resolve-agent recovers the
reproduction (verdict, per-facet breakdown, journey, the authored e2e test) and
drives the bug to resolution.
Two paths, decided by whether an open PR already fixes the bug:
- Review path: check out the existing PR, run the repro test against it
(pass = it fixes the bug; fail = it doesn't), review the diff, and comment
findings on that PR — no competing PR opened.
- Author path: audit the repro test against the unfixed tree so it fails on real
buggy behavior, root-cause, fix, add targeted tests at the changed layer, and
prove every live facet goes fail->pass.
Robustness on the author path: hostile-env rerun of env-default tests; an
independent cross-vendor review (a codex-native reviewer child on its own diff,
fed a recurring-pitfalls checklist) before opening the PR, reusing the server +
runner it already runs on. Opens a ready-for-review PR; does not merge.
--skip-push commits locally without pushing.
dev/resolve.py mirrors dev/repro.py; tests/dev/test_resolve.py unit-tests the
driver helpers.
Co-authored-by: Isaac
* dev/resolve-agent: address PR review — base off origin/main, stricter ci-link parse, honest guard comment
Review feedback on #4127:
- Base the fix worktree on the latest origin/main, not this checkout's HEAD.
Running the driver from a feature branch would otherwise drag unrelated
commits into the fix worktree and contaminate the PR/review. Adds
_resolve_base_ref() (fetch origin/main, fall back to local main, then HEAD).
- Confirm before creating the worktree, so answering "no" no longer leaves an
orphaned fix/<slug> worktree + branch on disk.
- Parse the --ci-link URL structurally (scheme + github.com host + anchored
path) instead of an unanchored substring regex, so a string that merely
contains the run path (or a different host) is rejected. Adds rejection tests.
- Soften the headless_subagent_purpose_guard comment in config.yaml: it only
inspects sys_session_send, not the sys_session_create that launches the
reviewer child, so it does not itself constrain that child — spawn_bounds caps
the fan-out and the reviewer's read-only behavior rests on its prompt + the
codex bundle's guardrails.
- Fix two inaccurate inline comments (worktree base, absolute-agent-path
rationale) to match the actual flow.
Co-authored-by: Isaac
* dev/resolve-agent: recover the pasted test from CI logs (repro-agent #4207)
repro-agent now pastes the complete verbatim e2e test source into its final
message before the JSON handoff. The CI job log echoes that message untruncated,
so on the --ci-link path the log itself now carries the full test body — prefer
reading it from the inline block there, with gh run download as the fallback.
(A live --session transcript is still truncated, so the disk read off the repro
session's workspace stays the robust path locally.)
Co-authored-by: Isaac
`omni host stop` pre-checks `GET /v1/sessions` so it never terminates a
daemon out from under live sessions. That API is one of the slowest on
managed, so the pre-check times out on otherwise healthy hosts and the
command fails with a bare `session list failed: ReadTimeout`.
`--force` already skips the pre-check and stops the daemon anyway, but
the failure never said so, leaving the daemon looking unstoppable. Name
both escape hatches in the error instead.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* docs(repro-agent): keep the journey user-observable, not a mechanism trace
The repro-agent was conflating the reproduction *journey* with the bug's
root-cause analysis: when a report named code paths, it verified those paths
(code traces / unit tests) instead of driving the observable user journey, and
packed the failure mechanism into the one-line `journey` field.
Sharpen the spec so the journey is strictly an ordered list of user actions
ending in a user-visible failure:
- Step 1: define the journey as concrete numbered user actions; a named code
path is a hypothesis to confirm as a facet, not the thing to verify. When a
report has no clear "Steps to reproduce", derive the journey rather than
adopting the root-cause analysis; if no reproducible user journey exists,
stop with needs_more_info.
- `journey` output field: the ordered user actions compacted to one line, with
the internal mechanism kept out (it belongs in facets/evidence).
- Also require pasting the authored e2e test source inline, immediately before
the JSON handoff block, so the reproduction test is visible when browsing the
session.
Co-authored-by: Isaac
* docs(repro-agent): require the inline test be complete, not elided
The agent pasted the test with the body replaced by a `# ... (see full file)`
placeholder, defeating the point of showing it inline. Spell out that the inline
block must be the whole file byte-for-byte, with no truncation, summary, or
placeholder.
Co-authored-by: Isaac
* docs(repro-agent): cover passive/time/system triggers as journey steps
The journey rules leaned on active user actions (click, type, send), so for
lifecycle/timeout bugs (e.g. an idle-timeout teardown hang) the agent had no
"action" to anchor on and fell back to dumping the mechanism trace into the
journey field. Spell out that passive triggers — waiting through a timeout, a
runner shutdown, a network drop — are journey steps, written as the observable
condition, not the code they run.
Co-authored-by: Isaac
* fix(runner): fall back to SDK/OIDC when managed mint fails due to expired proxy bearer
Host-launched runners start with a host-injected bearer
(RUNNER_INITIAL_AUTH_TOKEN) that expires after ~1h. When it expires,
_InitialAuthTokenFactory's fallback tries managed mint using
_last_initial_token as the proxy bearer — but that bearer is also expired,
so the Apps proxy returns 403 on every mint attempt. Previously 403 was
not in the decline set, so the factory stayed installed, returning None
forever and 403-looping on every callback.
Fix: introduce proxy_auth_failed on _ManagedMintTokenFactory, set when a
mint gets 401/403 with no prior successful mint. _make_managed_mint_factory
treats this the same as declined (returns None), so _make_auth_token_factory
falls through to SDK/OIDC auth instead of staying stuck on a dead bearer.
The _RunnerDatabricksAuth auth_flow also raises RequestError (not bare
request) when proxy_auth_failed, so the outer retry machinery can attempt
a credential refresh via the next path in resolution order.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: re-resolve fallback in InitialAuthTokenFactory when proxy auth fails
The previous commit's RequestError path in auth_flow was wrong — it
propagated the error to callers without rebuilding the factory, so the
runner still had no credential.
The actual fix: when _InitialAuthTokenFactory's fallback factory has
proxy_auth_failed (managed mint 401/403'd on the expired initial bearer),
re-resolve the fallback without a proxy bearer so _make_auth_token_factory
falls through to SDK/OIDC auth instead.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: skip managed mint on proxy_auth_failed re-resolve to avoid loop
The re-resolve after proxy_auth_failed was calling _make_auth_token_factory
without _allow_delegated_mint=False, so it could hit managed mint again
(no proxy_bearer this time), get 403 from Omnigent, set proxy_auth_failed
again, and loop. Use _allow_delegated_mint=False to go straight to SDK/OIDC.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: log actionable databricks auth login hint when SDK credential is expired
When the host bootstrap bearer expires and the SDK/OIDC fallback also has
no valid credential, log an error with the exact command to re-authenticate
rather than silently returning None and dying with a generic 'check remote
server authentication' tunnel error.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: avoid CodeQL clear-text logging flag on server URL in error message
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: remove server URL from error log to resolve CodeQL finding
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Projects can now store a default base branch in their config, pre-filled
into the new-chat composer when naming a new worktree branch. The project
default takes precedence over the user-global default (Settings › Git),
falling through to it (then blank) when unset.
The field is shown only when the "Random worktree" default is on — a base
branch only forks a worktree — and is dropped from the stored config when
the toggle is off, so it can't linger as a stale invisible default.
Backend needs no change: projects.config is a client-owned JSON blob and
base_branch already flows through to worktree creation.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Archiving hides a session from the default view, but the pinned label
persisted — so an archived session stayed pinned and would resurface as a
pinned row if later unarchived. Drop the archiver's own per-user pin when
the archive flag flips to true. Per-user scoped (only the requester's key
is cleared) and a no-op via delete_label when the session wasn't pinned.
The pin-clear runs after the label upsert (so a same-request archive+pin
can't re-add the pin) and after the archive stop (so a raise can't leave
the session archived-but-not-stopped).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): stop the transcript fighting the reader's scroll
Scrolling back through a conversation bounced. Three causes, all in the
transcript's scroll handling:
- HistoryAutoLoader wrote scrollTop after every history prepend. An
imperative write cancels in-flight momentum, so a page landing mid-flick
yanked the transcript — measured on a 1000-item session as 32 corrections
of up to 2083px, every one of them while the wheel was still moving.
Native scroll anchoring does the same job off the main thread; hand it
back by dropping [overflow-anchor:none] and the manual correction.
- The fetch fired 500px from the top, so the page almost always arrived
while the reader was already at offset 0 — where the browser stops
anchoring. Fire 2.5 viewports early instead, so it settles off that edge.
- Streamdown gives every code block a flat 200px intrinsic size under
content-visibility: auto, so offscreen blocks laid out at 200px and
snapped to their real height (108-1735px) on the way in, shifting the
text and resizing the scrollbar. Blocks under content-visibility are
also excluded from anchor selection, so this had to go first for
anchoring to work at all.
Perceived motion on a real 1000-item session, scrolling to the top:
direction flips 68 -> 11, scroll writes 32 -> 0, and a prepend away from
the top edge now moves visible content by 0px.
The scrollbar itself is replaced with a constant-height one: paging older
history genuinely lengthens the document, so a proportional thumb shrinks
a step per page while reporting a size it cannot know yet.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover transcript scroll stability across history paging
Drives a real paginated transcript: parks at the bottom, escapes the
stick-to-bottom lock, then wheels up until older pages land, watching
whether anything assigns scrollTop and whether the scrollbar thumb ever
changes size.
Against the pre-fix ChatPage this reports writes of [53, 3851] and no
thumb at all; jsdom can show neither, having no layout, no scroll
anchoring and no compositor.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e-ui): regenerate visual baselines
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* fix(native): surface the upstream failure in the policy-eval relay 502
The runner's local policy-eval relay caught any upstream POST failure and
replied with BaseHTTPRequestHandler.send_error(502), whose stock http.server
HTML page carries no cause. The native policy hook truncates that page into
its fail-closed "Detail:", so an auth-refresh lapse (the refresh-capable
client raising "Databricks token refresh returned no token") reached users as
an opaque "server returned 502: <!DOCTYPE HTML>..." gateway blip. Emit a 502
whose plain-text body names the upstream exception so the blocked-turn reason
is actionable.
Does not change the token-refresh behavior itself; that failure is tracked
separately.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(native): keep the policy-eval relay 502 detail intact and logged
Address Polly review feedback on the upstream-failure 502 body:
- Truncate the failure detail before prepending the fixed prefix, so the
leading actionable cause always survives rather than being cut mid-reason
once the length cap is applied to the whole message.
- Log the full exception (with traceback) to the runner log alongside the
capped user-facing body, since the cap can drop a diagnostically useful tail.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(ci): gate duplicate comments behind a flag; add a manual dry run
Duplicate detection was commenting on every issue it triaged, including the
common case where it found nothing — "I did not find an existing issue that
confidently matches this report" is a bot announcing a non-event on the
majority of issues. The wording also leaked classifier internals ("candidates",
"automatic checks do not establish") and buried the one actionable line, the
issue link, under two sentences of hedging.
Turn commenting off by default while the classifier is calibrated, and add a
`workflow_dispatch` dry run so a decision can be inspected against any issue
without writing to it. Detection and labeling are unchanged, so the workflow
log still records every verdict and confidence.
- `ISSUE_TRIAGE_POST_DUPLICATE_COMMENTS` (default false) gates commenting; a
`none` verdict now builds no comment at all, so enabling it only ever speaks
up when there is an issue to point at.
- Manual dispatch takes an issue number plus `apply_labels` / `post_comment`,
both defaulting off. It classifies as an `opened` event so the full duplicate
path runs, and logs the comment it would have posted.
- Reword both remaining comments to lead with the issue link and drop the
internal vocabulary. The closing case now carries the model's own one-sentence
reason instead of a fixed string.
The model's reason derives from untrusted issue content, so it is sanitized
before it reaches a public comment: URLs replaced, mentions stripped of their
`@`, issue refs generalized, one sentence, length-capped. Previously no model
prose was ever posted, so this is a new surface — covered by tests asserting an
injected mention, link, and issue ref cannot survive.
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Isaac
* fix(ci): make the triage dry run actually write nothing
Review on the dry run found three ways it could still mutate the issue it was
only supposed to inspect.
The `post_comment` gate used an Actions `a && b || c` ternary. Those return the
operand value, so a false middle operand falls through to `c`: dispatching with
`post_comment=false` evaluated to the repo variable and posted for real
whenever commenting was enabled. Pass the dispatch inputs through raw and
combine them in Python instead — the same shape would have been a latent trap
for every future boolean input, not just this one.
Only the label edit was gated, so a dry run still assigned the issue via both
assignment paths, and closure was gated by the repo variable alone — a dry run
against a duplicate could close it. Assignment and closure now ride on
`apply_labels` too, so with both inputs off nothing is written at all.
Sanitizer gaps on the closing reason, all reachable from untrusted issue prose:
`@@admin` matched the second `@` and left the first, rendering a live mention;
scheme-relative `//host` links stayed clickable; `GH-999` cross-linked. Match
`@` runs, add `//host` and `GH-<n>` to the patterns, and keep `50//50` prose
intact via a lookbehind.
Also rename `test_public_comment_uses_templated_reason` — it now asserts the
non-closing comment carries no model prose at all.
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Isaac
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(web): keep Pinned and Projects sections independent of the session filter
The sidebar's session filter (All / My sessions / Shared / Archived) is meant
to re-scope only the flat Sessions list, but the Pinned and Projects sections
were derived from the filtered slice, so switching filters emptied them:
- A pinned shared session vanished from Pinned on "My sessions", and a pinned
owned session vanished on "Shared sessions".
- The Projects group and its folders disappeared entirely on the Shared and
Archived tabs.
Both sections are now built from the full non-archived set (notArchived), so
they always show every pin and every project folder regardless of the active
filter. Only the flat Sessions list still re-scopes with the filter.
Add e2e UI coverage (multi-user server) asserting the Pinned section holds
owned + shared pins across My/Shared/Archived, and the Projects group + folder
survive the Shared/Archived filters. Update the mocked Sidebar unit tests to
match the new behavior.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): gate project-folder membership on ownership
Filing into a project is owner-only (unlike pins, which are ownership-
agnostic), but the project membership filter matched the legacy omni_project
label by project NAME alone. Since projectGroups now scopes to notArchived
(which includes sessions shared with the viewer), a shared session whose owner
used a project name colliding with one of the viewer's folders would be pulled
into that folder — and dropped from the flat Shared list via filedIds.
Gate membership on isOwnedByViewer so a folder only ever holds the viewer's
owned sessions, matching the owner-only filing model. Fix the two misleading
comments (Projects are NOT ownership-agnostic; Pinned shows every non-archived
pin). Add unit + e2e coverage for the project-name collision.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(web): move mixed-ownership Delete-count test to the flat list
The ownership guard on project-folder membership makes a folder owner-only, so
a folder can no longer hold another user's session — which was the premise of
the mixed-ownership Delete-count test (it seeded a foreign session into a
folder). With the guard, that foreign row now also renders in the flat Sessions
list, so the folder-based setup produced a duplicate "theirs" row and the query
threw.
Mixed ownership legitimately arises in the flat "All sessions" list (own +
shared), where the owned-count Delete label logic is identical. Re-seed the test
there instead of a project folder.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(claude-native): keep the working indicator alive across turns
Claude's `sessions/<pid>.json` is rewritten only when its value *changes*, so
a turn that starts while the file already reads `busy` produces no write at
all. Because the file poller muted the PTY watcher whenever it resolved,
nothing could publish `running` and the session sat on a stale `idle` for the
whole turn — no spinner and no stop button in the chat view, while the
terminal tab showed the live TUI. Nothing else can rescue it: for a parent
claude-native session the server deliberately does not publish `running`
optimistically, and the hook map carries only Stop -> idle / StopFailure ->
failed.
- resource_registry: the PTY watcher is never muted — pane activity always
publishes `running`. A quiet pane defers to the file only while
`asserts_running` reports it fresh, so a `busy` left standing by a
background task can't pin the session to running either.
- resource_registry: the publish-dedup moved onto the registry so a
forwarder's hook-derived edge rebases it. Without that the watcher still
believes its own `running` is live and swallows the next turn's edge.
- status_file: an unrecognized literal now drops the dedup baseline instead
of silently consuming the transition, and `asserts_running` finally
consumes `statusUpdatedAt`.
- Surface Claude's `waitingFor` through a new optional `waiting_for` field on
`session.status`, so a session parked on a dialog the web UI doesn't mirror
reads "Waiting: permission prompt" rather than a bare spinner.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the parked-reason working indicator
The E2E UI Required judge flagged that the working-indicator change ships
only unit tests. Add the Playwright test it wants, alongside the existing
`test_working_indicator_*` siblings: a turn in flight shows an ordinary
label, a `waiting_for` edge names what the agent is parked on, answering it
drops the reason, and the turn ending clears the indicator.
Driving that end to end needs the reason to survive the route a native
forwarder actually posts to, so `external_session_status` now carries
`waiting_for` too — the relay path already did.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor: rename the parked-reason field to blocked_on
`waiting_for` sat one word away from the `waiting` session status, which
means something unrelated — the turn ended and only background work remains
— and which must never be reused for a parked agent. `blocked_on` states
what the field is for and removes the collision.
Renames the field end to end (`blocked_on` on the wire, `blockedOn` in the
web store) and the label it drives, now "Blocked on: permission prompt".
Claude's own `waitingFor` key keeps its name where we read it — we translate
it into our vocabulary, as we already do for its busy/shell/idle literals.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix: use online serving for issue classification
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* docs: explain community issue prioritization
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* docs: fold issue prioritization into contributing guide
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(codex-native): point the exit resume hint at the session /new rotated into
Running a native `/new` in `omnigent codex` starts a fresh Codex thread, and
the forwarder rotates Omnigent ownership to a new conversation (recorded in
bridge state). Both CLI run paths still echoed the launch-time `prepared`
session id on exit, so the printed `--resume` command pointed at the session
the user had already cleared away from.
Read the active id from bridge state, falling back to `prepared.session_id`
when no rotation happened — matching what the Claude wrapper already does via
`read_active_session_id`.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(tests): repair stale helper name in claude-sdk replay redaction test
`test_historical_image_source_block_is_replaced_with_compact_placeholder`
imports `_render_prior_content`, but the function is named
`_render_prior_content_blocks`. The ImportError raised at class scope crashes
the pytest-xdist worker outright (`AttributeError: 'tuple' object has no
attribute 'value'` inside pytest's unittest plugin), so the whole
`Pytest (inner-rest)` shard fails with an INTERNALERROR rather than a normal
test failure.
Use the real name, and join the returned content blocks via the existing
`_text_of` helper since it returns blocks rather than a string.
Verified the test still guards the behavior it was written for: disabling the
base64 `source`-block arm of `_redact_inline_base64` makes it fail, and
restoring it makes it pass. Full file: 122 passed (was 1 failed + worker crash).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(codex-native): stop auto-create from 409ing the /new terminal transfer
A native Codex `/new` starts a fresh thread in the SAME terminal, and the
forwarder rotates Omnigent ownership onto a fresh session before transferring
that terminal onto it. Binding the runner to the new session triggered
auto-create, and the resulting second `codex:main` made the rotation's transfer
fail:
terminal transfer failed: Terminal 'codex':'main' already exists for
conversation '<new>'
httpx.HTTPStatusError: Client error '400 Bad Request' for url
.../resources/terminals/terminal_codex_main/transfer
Because `transfer_terminal` is what calls `set_conversation_link`, the failed
transfer left the tmux `Omnigent: <url>` footer — and terminal ownership —
pinned to the superseded session while the web session streamed from the new
one. Rotation itself then aborted mid-flight.
Add the transfer-inbound guard codex was missing: skip auto-create when the
session's bridge already names a *different* session owning a live
`codex:main`, and let the transfer deliver the terminal. Claude and
antigravity already do exactly this
(`_claude_native_terminal_arrives_via_transfer`,
`_antigravity_native_terminal_arrives_via_transfer`); this is the codex mirror.
Verified live: `terminal_inbound=True` -> transfer 200 OK -> "rotated Omnigent
session after native thread switch", and the PTY-captured footer moves to the
new conversation id after `/new`.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(ci): auto-close duplicate issues
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(ci): improve duplicate candidate recall
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix: search duplicate issues by terms
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix: harden duplicate issue closure
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix: preserve duplicate triage overrides
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat: gate duplicate issue closure
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* perf(triage): rank duplicates over the whole issue corpus
Keyword search was the real bottleneck on duplicate recall: across 11
recent issues it returned zero candidates for three of them and two or
fewer for four more, so the correct match never reached the LLM at all
(#4027's match was never retrieved). A query-dependent candidate set also
made IDF — and therefore the closure threshold — depend on what search
happened to return, so the same pair scored anywhere from 0.454 to 0.558.
Rank every issue in the repository instead. One `gh issue list` call
replaces the four search queries, fetches all 729 issues (open and
closed, so long-fixed reports stay discoverable) in ~10s, and scoring is
35ms. The candidate block sent to the model stays capped at 10.
Also strip code fences and traceback lines before tokenizing. Crash
reports share a long click/cli traceback template that scored unrelated
crashes at 0.79 cosine — above the close floor — which would have made
(DuplicateOptionError). Stripping drops that pair to 0.078 while genuine
repeats hold (#3359 -> #2993 stays at 0.956).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(web): stop transcript images loading slowly and shoving the page
Attachment images took seconds to appear when opening a conversation, and
pushed the transcript down as they landed. Three independent causes:
The content route was `async def` but called `file_store.get()` and
`artifact_store.get()` synchronously, so every image read blocked the event
loop -- while every neighbouring route in the file already offloads with
`asyncio.to_thread`. Against an S3-latency artifact store, 8 images took
749ms fully serialized and *no* concurrent request completed at all, so the
SSE stream and the rest of the transcript load stalled alongside them.
Offloading both calls drops that to 111ms with a 0.5ms median ping.
Content is immutable per file id -- there is no update endpoint, only
delete -- but the route sent no validators, so every session load
re-downloaded full-resolution originals. A strong ETag plus an immutable
Cache-Control takes revisiting a conversation from 1.1MB to 0 bytes.
The `<img>` reserved no space, so it laid out at ~0 height and jumped on
decode. Nothing absorbs that growth: the chat scroller runs with
`overflow-anchor: none` because history prepends own the anchoring, and
PreserveScrollDistanceOnResize early-returns off iOS. A fixed-height
preview box, an absolute cap on the image (`max-h-full` cannot resolve
through the lightbox's auto-height button wrapper), and a non-wrapping
image row take the push from 469px to 0px.
Note: a message carrying several images now scrolls horizontally instead of
wrapping onto multiple lines; wrapping re-flowed as widths resolved and
still moved the page 264px.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover the inline image preview holding its space
Asserts the layout guarantee the component tests cannot reach: jsdom has no
layout, so a unit test can check the box's classes but never that the image
actually occupies the space they promise.
Rather than race the network, the test renders the same seeded transcript
twice -- once with the image bytes aborted, once with them served -- and
requires the preview box and the reply beneath it to land identically. A
reserved box is the same height either way.
Verified it fails without the fix: the blocked render collapses the box from
180px to 16px and lifts the reply 164px.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
LIMIT was 3 so the comment's wording could get its first real-world read on a
bounded number of PRs. It has now posted on 8, including three first-time
contributors, and reads correctly.
Keep a cap rather than removing it: it bounds how far a mistake in the wording or the
predicate can reach in a single sweep, and 25 is above the current flagged count so
it no longer paces normal operation.
The ready-for-review gate has no LIMIT and needs none: applying a label notifies
nobody and is trivially reversible.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The gate had no author check, so it labelled maintainer PRs. Half the in-window PRs
are the team's own work, so labelling them halves the signal the label exists to
create: maintainers land their own changes and do not need routing into a review
queue. The nudge already exempts maintainers for the same reason, and the gate
should match it. Two of the four PRs labelled on the first enforcing run were
MEMBER-authored.
Detection uses both signals, like the nudge: a maintainer whose org membership is
private reads as CONTRIBUTOR, and one with write access may be missing from
.github/MAINTAINER. The file is read from the API rather than the checked-out tree,
so a PR cannot self-grant by editing it. Bots are skipped too.
Also skip closed and merged PRs. `is:open` in the search is index-backed and lags, so
a PR that closed in the last few minutes still comes back; the state we are handed is
now checked before writing.
Verified against production: 13 maintainer PRs now skip, and the two community PRs
already carrying the label keep it.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The gate has run dry since it merged and its verdicts hold up: the PRs it marks
ready all reference an open issue, are not drafts, and are not waiting on their
author. Nothing else has ever applied this label to a fresh PR, so until now the
label could not be used as a review queue.
No LIMIT, unlike the issue nudge. Applying a label notifies nobody and is trivially
reversible, so there is no first-run blast radius to bound. A maintainer who removes
it is respected: the sweep will not reapply a label a human took off.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Starting a session left the user on the landing screen for seconds after
hitting Send. The create POST doesn't answer until the host has finished
spawning a runner — a process boot, measured at 1.8-7.7 s — and the
screen navigated on that response. But the server writes the session row
and announces it on WS /v1/sessions/updates almost immediately, so the id
the UI is waiting for is available long before the response carries it.
Take the id from whichever arrives first. The chat page renders from the
id alone, so it opens right away and shows its own starting spinner while
the runner comes up.
The announcement can't be taken at face value, though: the stream carries
every session that becomes visible to this user — another tab, a
scheduled task, one just shared with them — with nothing tying a row back
to this create. And the id is not only the URL, it also keys the first
message handoff (setPendingInitialPrompt), so the wrong one would post
the user's message into somebody else's conversation. So the screen
matches the announced row against what it just asked for: never seen by
this tab, no parent_session_id, same agent_id, same host_id. The sandbox
path has no host to match on until the sandbox registers one, so it waits
for the response as before.
Winning on the announcement can't skip an error the user needed to see:
the workspace and agent are validated before the row is created, so a row
existing (and being announced) means the create already passed the checks
that produce a landing-screen error.
Measured end-to-end, click to session page open: 1862/2008/7664 ms ->
92/95/124/160/202 ms, with the create POST still in flight.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): give scheduled /loop wakes their own marked turns
Cron and wakeup firings re-invoke Claude with no user transcript
entry, so each iteration's output inherited the finished turn's
response id: the web merged the whole loop into one ever-growing
bubble whose fold read a bare 'Worked' (mixed clocks yield no
duration) and popped the full history open at every iteration.
The forwarder now records a turn's Stop edge as a settle — activated
only once the transcript is quiet, so a delta-held final message
can't be mis-read as a wake — and assistant output still inheriting a
settled id opens a fresh turn behind a '[System: scheduled prompt
fired]' marker. Each iteration folds as its own 'Worked for Xs' row,
and the web latches a shown fold so the next wake's running edge
(Working shimmer included) can't pop it open; only the bubble's own
turn reviving re-expands it.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): keep a scheduled wake's early deltas out of the finished turn
A wake's first text deltas stream ahead of the transcript batch that
names the new turn. The stray-idle revive read them as proof the
FINISHED turn was still live — reopening its fold at every /loop
iteration — and their preview blocks glued to the settled bubble,
breaking its fold eligibility and inflating its worked-for span.
Terminal edges now stamp completedAt on the active response; a delta
arriving past the revive window (stray idles are contradicted within
seconds, wakes fire at 60s minimum) neither revives the turn nor
renders a preview — the message is retired and its text lands via the
authoritative item in the new turn's bubble.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(claude-native): close three settle-latch edge cases from review
- A batch holding the compact summary AND post-compaction output parsed
the resume against the still-armed settle, mis-marking it as a
scheduled wake: the reader now disarms the settle mid-batch at the
summary record.
- Promotion now defers on ANY item for the settling turn (a late tool
result can surface earlier than the delta-held assistant tail;
promoting on it split the turn's own answer into a phantom wake).
- The pending settle persists in the transcript cursor, so a forwarder
restart between the Stop edge and the quiet-poll promotion no longer
reverts the next wake to the merged-bubble rendering (the hook cursor
is already past the Stop edge and cannot re-derive it).
- completedAt is stamped in the remaining finalizers so the stray-delta
gate covers every completed transition, not just status-edge paths.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The check has run dry for a day, and its verdicts have been audited against live
GitHub twice: every flagged PR genuinely references no issue, every exemption is
legitimate, and the two PRs whose bodies mention numbers point at pull requests
rather than issues. No PR carries the dedupe marker, so nothing is double-nudged
on the first enforcing run.
LIMIT is 3 rather than 25. The first enforcing run is the only one where a wording
mistake is unrecoverable, and several PRs in the current window are from first-time
contributors, so bound the blast radius while the comment gets its first real-world
read. Raise it once the live comments look right.
Setting ENFORCE back to "false" returns to a dry run at any point.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Two ways a PR could satisfy the issue rule without tracking any work, both found
on the first live run of the ready-for-review gate.
Quoted text counted. #4180 documents the bot's own comment, including the line
"`Part of #123`" inside a blockquote. #123 is a real issue, so the parser resolved
it and the PR satisfied its own rule. Fenced blocks had the same hole. Strip both
before scanning: quoted text is shown, not asserted. An unterminated fence
swallows the rest, which is the safe direction.
Closed and draft issues counted. A resolved issue is not tracked work and a draft
issue is not agreed work, but the resolver only checked that the target was not a
pull request.
Both checks now share one resolvesToOpenIssue. The gate previously carried its own
copy that tested only .pull_request, which is exactly how the two would drift on
what counts.
Note this drops #4095 from the ready set: its "Refs #3644" points at an issue that
has since closed.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): label fresh PRs waiting-for-review once they clear the bar
`waiting-for-review` had exactly one entrance: the handoff that fires when an
author replies to feedback. A PR nobody had touched yet sat in neither state, so
478 of 479 open PRs carry no review-state label and the label cannot yet be used
as a review queue.
A new sweep step applies it to PRs that clear the bar. The bar today is just
"references an issue", reusing pr-issue-link.js's resolution so the gate and the
nudge can never disagree about what counts. It is meant to rise: CI green, demo
present, Polly clean each become a predicate in `belowBar`.
Never applied to a draft, to a PR already carrying `waiting-on-author` (which
would break the mutual exclusion the pair relies on), or to a PR whose label a
human removed before, since a sweep that reapplies it hourly would be arguing
with the maintainer who took it off. Forward-only, sharing the issue-link
effective date, because labelling the whole backlog at once would bury the signal.
Ships dry-run. Verified against production with the label write rigged to throw:
26 PRs in the window, 4 ready, 20 below bar, 2 drafts skipped, no writes attempted.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): only treat a human removal as "not ready"
removedBefore matched any removal of waiting-for-review, ignoring the actor the
query already fetched. But waiting_on_author.py removes that label itself on every
waiting-on-author transition, since the two are mutually exclusive, so the bot's
own routine state change was read as a maintainer saying "not ready".
The effect was permanent: a PR that had been through one review round trip and then
ended up in neither state, which is exactly the gap this gate exists to close, would
never be re-labelled. Confirmed on a real PR from earlier today whose timeline
records "unlabeled waiting-for-review by github-actions[bot]".
Rename to removedByHuman and filter out [bot] actors. A missing actor fails toward
eligible, since a removal we cannot attribute is not evidence of intent.
Also make the label write per-PR so one failure no longer abandons the rest of the
sweep, matching the resilience close_stale_waiting_prs already has.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(telemetry): routing decision and setting-change events
Routing needs to be answerable after the fact: which arm the router
picked, whether it was applied, and what the user changed. Adds
``RoutingDecisionEvent`` and ``RoutingSettingChangedEvent`` plus a
``model_labels`` helper that reduces a model id to a family/tier pair, so
records stay useful without carrying raw model ids.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(sessions): persist routing decisions and session warnings
A routing decision has to survive the turn that produced it, so the UI
can show what the router chose and — crucially — whether it was actually
applied. Adds ``RoutingDecisionData`` to the conversation entity with
store support, and a ``session_warnings`` module for the non-fatal
routing conditions a session needs to surface (router unreachable,
verdict not applied) without failing the turn.
Records are honest by construction: a decision that could not be applied
is stored with ``applied=false`` and its reason rather than being
dropped or reported as a success.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(routing): session-start smart routing core
Adds the server-side routing core behind Smart Routing: an external
``task_v1`` route-options seam that offers the router the frozen arm menu
its scenario requires, maps a pick back onto a servable catalog id via
nearest-cost substitution, and derives the harness that can actually run
it. Routing settings become one value object on ``RuntimeCaps`` so every
consumer reads the same knobs instead of re-parsing config. Databricks
model discovery resolves catalog spellings deterministically so the same
endpoint is named the same way on every path.
Reconciled against main's catalog-driven routing:
- Main's ``_fetch_runner_catalog`` / ``_RunnerModel`` plumbing and its
cost-tier ordering are the single source of live model availability;
``fetch_runner_models`` remains the id-only adapter over it.
- Main's ``ModelIntent``-parameterized judge rubric replaces the
family-specific tier hints.
- Main's catalog wire-API check survives as
``_redirect_wire_incompatible_pick``, layered after the static
``_HARNESS_EXCLUDED_MODELS`` bar list. The two cover different things:
the catalog knows what an endpoint advertises, the bar list knows the
client-side rejections it does not.
- ``model_family_token`` defers to ``is_codex_compatible_model`` so the
GLM/Kimi delegate arms read as the codex family everywhere.
The static ``MODEL_LISTS`` table is retained, unlike main, because the
nearest-cost substitution needs a family cost ordering on paths with no
catalog in reach (hook scripts, pre-session creates).
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(server): route sessions at start and expose the decision
Wires the routing core into session lifecycle. A session created in
Smart Routing mode is routed once, at start, from the first user message:
the verdict picks the harness and the model before the runner launches,
and pre-launch host model options supply the candidate catalog when no
runner exists yet. Later turns never re-route — a session's harness is
settled once so a conversation cannot change identity underneath the
user.
The decision is exposed on the session snapshot and event stream with
its applied state, so the UI can distinguish "the router picked X and we
are running X" from "the router picked X and we could not apply it",
rather than silently showing the request as the outcome.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(claude): apply a routed model to Claude Code
A routed arm only matters if the harness actually runs it. Adds a Claude
model vocabulary that maps between router arm ids, catalog spellings, and
the ``/model`` names Claude Code accepts, and pins the CLI's family
aliases to the frozen task_v1 Claude arms at launch so the first turn's
switch can reach whatever the router picked.
The vocabulary reads its catalog prefixes from one definition shared with
the server seam, so the hook path — which cannot read server config —
cannot drift from it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(codex): apply a routed model to Codex
The Codex side of the apply layer: the native app server and executor
accept a routed model override and enforce it on the session they launch,
so a verdict that names a GLM/Kimi delegate arm reaches the CLI instead
of being dropped for the harness default.
Codex spawns with no routable signal skip the router outright rather
than routing on an empty prompt and recording a decision nobody asked
for.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(routing): route sub-agent spawns from harness hooks
Sub-agents spawned by a native CLI never pass through the server's
session-create path, so they were unroutable. Adds hook scripts the
Claude and Codex CLIs invoke at spawn time, plus a runner-side router
that answers them, so a spawned child is routed on its own task text and
launched on the chosen model.
A child is only ever offered its parent's harness family: routing may
change which model a sub-agent runs, never which vendor it belongs to.
Hook commands run under ``python -I`` so a repo-local module on the CLI's
cwd cannot shadow the interpreter's own imports.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(web): surface routing decisions and Smart Routing controls
Adds the Smart Routing harness option to new-chat, a routing chip that
shows the routed model on the session, a sub-agent routing row, and a
warning banner for the non-fatal routing conditions the server reports.
The chip reports what actually happened. When a decision could not be
applied it says so and names the model in use, instead of showing the
router's request as though it were the outcome.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test(routing): cover the routing apply layer end to end
Adds the remaining routing coverage: the CLI's routing-client build, the
native Smart Routing create path, an end-to-end routing integration test,
and the discovery/override unit tests. Also updates the existing native
bridge, forwarder, and launch-arg tests for the model-override plumbing.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs(routing): record the routing design and verification state
Captures the plan the implementation followed, the per-CUJ verification
status, and the observed live-model state the harness bar list is derived
from — the gateway rejections that catalog metadata does not advertise.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: registry stamps — rebased-tree battery green, session-start verified live
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: re-sync CUJ walkthrough with the rebased tree
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(routing): offer Smart Routing only where the apply layer can work
Smart Routing rewrites a launch's model through the Databricks AI Gateway,
so a host whose claude-native or codex inference resolves anywhere else
(Bedrock, a plain API key, the vendor CLI's own login) got an option that
could never take effect. Gate each surface on the fact that decides it.
The host already resolves this at launch, so reuse those resolutions as a
cheap config-only check — no process launch, no network — and report a
`gateway_inference` map alongside `configured_harnesses` on registration
and every readiness refresh. It rides the host frames into the store and
out through GET /v1/hosts. A host that never reports it sends `null`, and
`null` means unknown: nothing is gated away on older host builds.
Web gates the three surfaces independently, classified in the single
`smartRoutingAvailability` point as a new `not-gateway-backed` cause:
Configure Claude Code's Model row needs the claude family, Configure
Codex's needs the codex family, and the top-level Smart Routing harness
row needs both (it drives the five-arm menu).
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs(routing): record the gateway-backed availability decision
Plan §10 gains decision 9 (Smart Routing offered only where the apply
layer can work, with the per-surface rule and the absent-means-unknown
compatibility contract), and §8 gains the two follow-ups it defers: a
liveness probe, and moving the routes:select call host-side so routing
auth/workspace always matches the host's inference.
CUJ_STATUS gains recipe R9 (point a host at a non-AIGW config and assert
the option disappears) plus one pending check row per gated surface.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: rewrite the CUJ walkthrough in simplified technical English
Rewrite designs/CUJ_IMPLEMENTATION.md in ASD-STE100-inspired Simplified
Technical English so every sentence parses one way only: active voice with a
named actor, simple tenses, one statement per sentence, noun clusters of at
most three words, and lists for any sequence of three or more steps. Add a
six-term glossary (arm, seam, pane, rollout, canary, spelling) to the intro.
Remove the hard 80-column wrapping so each paragraph is one soft-wrapped line.
No facts change: every sha citation and every file:line reference is
byte-identical to bc4b6c0.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: stamp the gateway-inference positive half
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: keep the routing design docs local-only
The four routing design documents (plan, test registry, CUJ walkthrough,
live model state) stay on disk for local reference but leave version
control — they are working notes, not reviewable deliverables.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): serve turn routing the launch-exact claude vocabulary
Two claude-path defects from the live verification round.
Turn-1 routing on a claude-native pane could substitute the routed arm.
`_native_turn_catalog` read `_model_options_cache` without consulting
`_model_options_stale`, so a catalog hydrated from the session's *host*
before launch (whose family aliases carry the workspace default) became
the offered vocabulary. With the launch pinning `opus ->
databricks-claude-opus-4-8` and turn 1 routing ~100ms later, the pinned
arm had no spelling on offer and the router substituted sonnet. Turn
routing now awaits a refetch from the bound runner's
`claude-model-options` endpoint — which reports the launch-pinned
aliases — whenever the cached entry is stale, and falls back to the
stale catalog when no runner can answer.
Every claude-native turn also 400'd with `invalid beta flag`: the ucode
gateway launch env never set `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`,
and Claude Code 2.1.220 sends three flags the Databricks gateway
rejects (`prompt-caching-scope-2026-01-05`, `advisor-tool-2026-03-01`
and, under `ENABLE_TOOL_SEARCH`, `advanced-tool-use-2025-11-20`), which
fails the whole request. Set the knob on that path too.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): no substitution arrow for prefix-only subagent raw picks
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): float the session warning banner over the chat
The session warning strip rendered in-flow between the chat header and
<main>, so a warning arriving mid-session pushed the whole conversation
down. Render it as an overlay instead, on the same positioning contract
as the chat header: anchored inside the chat column, below the header,
stopping short of the workspace panel via --workspace-panel-offset, and
transparent to pointer events outside its own rows so the chat stays
scrollable. Multiple warnings stack downward inside the overlay.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): gate the codex canary check on a real turn, clear it per launch
`subagent_routing_unenforced` was posted on codex-native sessions whose
routing hooks were in fact trusted and running. Codex dispatches
`SessionStart` (the canary) when a thread's *first turn* begins, but the
enforcement watcher's first-turn gate was released by any
`thread/status/changed → active` or `item/*` event — and the MCP startup
round activates the thread and emits items without running a turn. So a
session that had not been asked anything yet (or whose first turn was
interrupted before it started) failed the canary check 30s later. Live
evidence (session e6074fb1...): thread activated by the MCP startup round
at 13:58:06, warning posted at 13:58:36, and the canary file for that same
session/app-server finally appeared at 14:01:36 when a real turn ran —
proving the hooks were trusted and effective. The stale warning stuck only
because the runner was stopped before the repair tick.
Direct probes against `codex app-server` (isolated CODEX_HOME) also
disprove the "codex captures hook trust at process start" theory: trust
written after the spawn (the shipped ordering) takes effect, even for a
turn already in flight when `config/batchWrite` lands. The real invariant
is that trust must land before the first *turn*, which `start()` already
guarantees — now written down where it can be broken.
Second fix: the canary is the proof that *this* launch's hooks ran, so
`clear_bridge_state` now drops it. The per-workspace bridge dir is reused
across launches, and a canary left by an earlier launch masked a genuine
fail-open for the rest of the session. Transition-only posting still
clears a previous launch's warning on the new forwarder's first check.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): clear the codex spawn audit per launch too
Same staleness class as the canary (51e36c8c): the audit is reconciled
against the routing decisions *this* launch's endpoint relayed, so a line
left by a previous launch — whose approving decision lives in that
launch's router — reads as a spawn the router never approved. The
per-workspace bridge dir is reused across launches, so `clear_bridge_state`
now drops the audit alongside the canary.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(routing): apply the glm arm under the gateway's model route
The task_v1 codex arm `glm-5-2` resolved to the catalog's
`databricks-glm-5-2`, which the codex turn then failed to serve: that
serving endpoint advertises chat-completions only and 400s on
`/codex/v1`. Probes on staging and prod (2026-08-01) show the Responses
API does serve GLM — but only under the gateway model route
`system.ai.glm-5-2`. GLM appears in no discovery listing, so the working
name can only be pinned, not discovered.
Add a per-model servable-alias map next to the arm tables and consult it
when an arm resolves to a servable id, so the codex apply layer writes
`system.ai.glm-5-2`. Subagent candidates are offered under the same
spelling, so a rewrite spawns with the id routing resolves to. The
router's arm id stays `glm-5-2`, and the alias strips to the same bare id
so decision records show no substitution.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: track the routing design docs again
Re-adds the plan (with the decision log), the test registry, the
enumerated CUJ walkthrough, and the codex model-state notes, all
current as of the post-verification state.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(routing): route the model at create time for a fixed native harness
A native terminal launches with the session row and its turns originate in
the TUI, so the server never sees the first message pre-inference — the turn
gate that routes a plain claude/codex session never fires for a CLI-driven
one. Create-time routing existed only on the `harness_override: "auto"` path,
which picks harness AND model.
A create that carries `cost_control_mode_override: "on"`, a non-empty
`smart_routing_message`, and a FIXED native harness (claude-native /
codex-native, via the wrapper agent, `harness_override`, or the spec) now
routes its MODEL during the create: candidates come from the host's
pre-launch catalog for that one harness, the pick is constrained to it, and
the routed id is persisted as `model_override` with the routing-decision
label plus a session-scoped decision record. Fails open — an unconfigured
router, or a pick the harness cannot run, pins nothing and records the
reason, so the session still opens on the CLI's default model.
Session-start cadence is unchanged: the pinned model closes the per-turn gate
exactly as the auto path's create pin does. The branch is skipped for SDK
harnesses (which still route on their first turn), child and sub-agent
sessions, and a create that pinned its own model.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat(cli): route the model (and harness) before a native TUI launch
Smart Routing was web-only: a CLI user who wanted the server to pick a
model had to start the session in the browser. Add the two launch surfaces
Bryan asked for, both of which route *before* anything starts — the harness
pick is physical (a session is a live claude/codex process) and the model is
applied as a launch flag, so there is nothing to change after the fact.
- `omnigent claude|codex --smart-routing -p "<prompt>"` and
`run --harness <native> --smart-routing -p ...` route the model and keep
the requested harness.
- `omnigent run --smart-routing -p "<prompt>"` (no --harness, or
`--harness auto`) routes harness *and* model, then launches that wrapper.
One session, routed at create: the CLI creates it through the standard JSON
`POST /v1/sessions` (bound to the host it will run on, whose model options
are the router's candidate catalog) and the wrapper ATTACHES to it instead
of bundling its own. The row the server writes already carries the agent
binding, the wrapper's presentation labels, the routed model and the
decision card, so a routed CLI launch gets the same chip and provenance the
web UI does. The resolved harness is read from `SessionResponse.harness`;
native rows leave `harness_override` null on purpose.
`--smart-routing` requires `-p`: routing needs text, and the degraded
route-on-turn-2 mode is not shipping, so an empty invocation is a usage
error pointing at `-p` or the web UI. It also rejects an AGENT, the
REPL-only flags, and `--resume`/`--continue` (routing is a create-time
decision, so a routed launch is always a new session). Preflight
(`smart_routing_enabled` plus the host's per-harness `gateway_inference`)
is a hard error naming the reason, because a routed model the pane cannot
reach is worse than no pick; the create itself always fails open — the
wrapper then starts a plain session behind one notice line.
`omnigent claude` also gains `-p`, and claude/codex now accept a prompt
through `run --harness <native> -p` instead of rejecting it. The prompt
travels as argv (Claude Code's positional prompt; Codex keeps its existing
first-turn delivery), so multi-line prompts survive intact.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(cli): resolve the claude agent name from harness_plugins on this branch
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: PR rewrite plan — cut list, commit series, CLI integration
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* chore: track the isolated dev-stack scripts the test registry references
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: cover the glm gateway-route fix
907f8886 pins the id the glm arm is applied under: the gateway serves GLM
on the Responses API only as the model route `system.ai.glm-5-2`, so the
catalog's `databricks-glm-5-2` row 400s every codex turn. Record the
mechanics in CUJ_IMPLEMENTATION.md §3.5h (with the §1.3 spelling note and
the residual "pinned, not discovered" open item), and close the C1 /
§2.8 blocker in CUJ_STATUS.md against the live session 80fb6d1f: config
mirror and every rollout turn context on system.ai.glm-5-2, zero
BAD_REQUEST, real generation. The only error left on that thread is a
gateway-capacity 429, which is load and not routing.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: cover the CLI smart-routing entry points
`omnigent claude|codex --smart-routing -p` (tier 2) and `omnigent run
--smart-routing -p` (tier 3) were undocumented. Record the fourth surface:
CUJ_IMPLEMENTATION.md gains §6 (commands and tiers, prompt delivery,
preflight, the create-time MODEL route for a fixed native harness, the
create the CLI drives, rejected combinations, the routed launch, decision
persistence, and the agent-name import fix), and known-open moves to §7.
CUJ_STATUS.md gains recipe R10 and §2.10 — unit rows stamped from the three
suites that pass at HEAD, every process-truth row ⬜ because no routed CLI
launch has run live yet.
PR_REWRITE_PLAN.md §2d/§5 corrected: both CLI halves have merged, and the
tier-2 server half is already its own commit, so the commit-3/commit-8 split
is mechanical. The CLI commit did not extend `_resolve_native_smart_routing`
— the fixed-harness route is a parallel path — but it does share the auto
path's lifted `_routing_host_for_create` helper, which the assembler must
keep.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: track the PR review fix list (rounds 1-2, all items addressed)
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: high-level routing system map for slimming iteration
Add designs/ROUTING_OVERVIEW.md: a one-altitude map of the Smart Routing
feature — the four user journeys, the fifteen subsystems with size and
rewrite fate, the invariants that must survive any cut, and the five open
decisions. Written in ASD-STE100 style with block IDs so the slimming
pass can cut and keep by reference.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: fold Bryan's critique decisions into the rewrite plan
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: fold the model-resolution rulings into the plans; STE pass on the rewrite plan
Bryan ruled on the three open resolution questions (2026-08-01): revert
the resolution machinery to main's shape (cut MODEL_LISTS, the cost
table, the allowlist), drop pi from the routed set for now (bar list
goes with it), and use one fixed fallback model per family (claude ->
sonnet, gpt -> terra) with an honest decline behind it. The rewrite
plan is now fully decided and rewritten in ASD-STE100 style; the
overview's subsystem fates, invariants, and decision records match.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: finish the STE pass, restructure 3i to the three rulings, pin the fallback-id assumptions
Reconciles the fold-agent's late completion (it amended 0baeea1c
locally; this lands the same tree as a follow-up commit instead of a
force-push). The whole plan now meets the STE caps, 3i lists Bryan's
three rulings as ruled (pi had been displaced by a mechanism bullet),
and the open-assumption list grows to three: glm declines with no
fallback; terra is today only a pi-exclusion entry, so the code must
add it as a servable target; sonnet pins to databricks-claude-sonnet-5.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: luna is the gpt+glm fallback, sonnet follows the alias pin; add verification criteria (6c-6e)
Bryan's final fallback rulings (2026-08-01): the gpt and glm families
both fall back to luna (databricks-gpt-5-6-luna, itself a frozen arm,
so a glm fallback never leaves the codex harness), and the claude
fallback is whatever the sonnet alias pin resolves to rather than a
hardcoded id. Terra is out; glm no longer declines. No open
assumptions remain in the plan.
New plan blocks 6c-6e state the verification criteria: the evidence
bars per layer, the registry recipe handles (R0-R10; R8 dies with the
enforcement cut), and the per-slice verification gates for the fleet.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: switch the plan to a from-scratch rewrite (7g)
Bryan chose a complete rewrite from scratch (2026-08-02) to keep the
new code as clean as possible, reversing the plan's earlier 'assemble,
do not re-implement' constraint.
The scope decisions all survive; the method and the safety net change.
New blocks: 0c names the three inputs an agent must read before it
writes a slice (the behavior inventory, the trap list, and the
reference implementation on routing-mvp-v1), 0d says to rewrite the
shape but transcribe the empirically-derived constants, 3l reframes
the cut list as 'do not build', 4e contains the integration risk that
moves to the end, 6f records that no evidence transfers, and 7g is the
decision itself. 3j becomes a ceiling rather than a subtraction, which
also retires its old arithmetic gap, and 5b turns the two CLI commits
into specifications rather than patches to apply.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: request-time managed flag, parallel wave plan, and four scope reversals
Bryan's review of the rewrite plan (2026-08-02) produced five changes.
The managed preview flag is evaluated per request, not at
construction, and it moves out of 2a into its own block 2f: flag off
routes through the naive LLM judge, flag on routes through the AI
Gateway, so a flag-off workspace degrades rather than loses the
feature. That also dissolves the managed-swap report's objection.
The glm gateway route is codex work, not CLI work, and the Smart
Routing harness inherits it because it runs codex underneath.
Cross-harness spawning is reinstated: harness agents get
sys_session_create instead of a deny message (3c, 7i). Telemetry
leaves the PR entirely for a follow-up Bryan owns (3e, 7j). The design
docs ride the branch for his reference and a final commit deletes them
before merge, so no docs PR exists (3a, 7j).
Execution is now three waves of five or six parallel workstreams on
one branch, preceded by a lead-authored wave-0 contract commit that
declares every shared signature and pre-creates every shared touch
point (4a, 4b, 4e, 6a, 6e, 7k). Size is a preference for
reviewability, not a target (3j).
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: make the rewrite plan readable without session context
The plan hands off to a fresh fleet that has none of this session's
history, so the spec sections (0-6) now read as instructions rather
than as diffs against earlier drafts. Removed the negations of
assumptions a new reader never held (the glm route is "not CLI work",
managed readiness is "not 2a", 3c "reverses the earlier cut"), the
RESOLVED-with-date tags inside spec blocks, and references only this
session could resolve. Section 7 keeps the full decision record, which
is its job. Empirical findings survive the trim: the A-sub
deny-message result, the zero-live-triggers evidence, and the
authorization-order trap now cite the document that records them.
Wave design is now the lead's rather than a placeholder: a wave-0
contract commit, 7 foundation streams, 6 integration streams, and a
4-stream closure wave. The turn gate and the create paths move into
separate modules so they stop colliding in orchestration.py; web and
CLI move into wave 2 behind the wave-0 HTTP contract, which keeps the
two largest surfaces off the critical path. Barrier 1 gains a real
check (apply a hardcoded model to a claude pane and a codex session
with no router involved) and barrier 3 gains the flag-off backend row.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: clear the last session-only references from the plan
3g was still written as "rewrite, not transplant" against a suite the
fleet never sees, and it cited a commit's method rather than a rule.
It now states the rule directly: start from the behavior inventory in
CUJ_STATUS.md section 2, one test per behavior, coverage as the gate.
The reference suite is described as what not to copy and why.
Also replaced the two remaining "three review waves" references, which
name history a fresh reader cannot resolve, with "the reference
implementation".
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: close the cold-read audit's blockers on the rewrite plan
A subagent with no context from this session read the plan as an
executor would and found that its load-bearing inputs are unreachable
from the branch it tells you to start on. Confirmed and fixed.
Blockers:
- routing-mvp-v1 was an aspiration, not a branch. It now exists,
pinned at f200a8bd, and 0c/1a cite the sha.
- None of the required-reading docs, and none of the R0/R6/R9/R10
verification harness, exists on origin/main. Wave 0 now carries all
twelve paths across, or every stream stops at its first instruction
and both live barriers have no stack to run on.
- 2f never named the preview flag. It is managed-side
(databricks.mas.omnigent.intelligentRouting, default off), so OSS
gets a per-request predicate the deployment supplies, plus a
default; stream 2 builds the seam, not a flag system.
- The migration had two owners. Wave 0 creates the empty revision and
stream 4 fills it.
- The file partition existed only as a promise, and where implied it
double-booked subagent_routing.py. New block 4f is the table, with
named modules for the transport/policy and turn-gate/create-path
splits, and cli.py declared lead-owned.
Also: new 2g records what main already ships (both routing clients and
the wire-compat redirect), which shrinks stream 2; wave 0 slims the
registry so waves 1-2 are gated on a true list; 6d had R5 and R6
transposed; 6e dropped row B3 and now names CUJ_STATUS as the row
authority; barrier-1's apply script has an owner; the UI acceptance
names Bryan, since no agent can close it; and the size figures in 1a
and 3h are re-measured (29,924/155, and web/src minus its lockfile).
One gap only Bryan can close, now flagged in 6e: INTELLIGENT_ROUTING_
PLAN.md section 11.1 does not embed the P-SOL prompt, and rows A3, B2,
C2 need it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: add LOCAL_SETUP.md; drop the stray npm lockfile
R0 documented how to run the stack but not how to build it, and two
things stopped a fresh machine cold: .omnigent-local/config.yaml is
gitignored, so run-server.sh exits immediately with nothing explaining
what belongs in it, and run-frontend.sh hardcoded this machine's nvm
path. LOCAL_SETUP.md now covers prerequisites, uv sync + pnpm install,
the databricks profile the router needs, the config template (with the
two details that break things quietly: system.ai. keeps its trailing
dot, and router_name must be task_v1), bring-up, a health check, the
known local quirks, and teardown. R0 points at it and wave 0 carries
it across.
run-frontend.sh now resolves node from PATH, falling back to the newest
nvm install, and fails with a pointer if pnpm is missing.
Separately: web/package-lock.json was tracked again after the rebase.
The repo uses pnpm (pnpm-lock.yaml, packageManager pnpm@11.15.1) and
main has no npm lockfile, so this was 3,451 lines of generated
wrong-package-manager noise in the PR diff. Untracked, deleted, and
gitignored so it cannot come back.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: record the personal CLI setup and the provider topology
LOCAL_SETUP.md covered the repo, but a fresh clone still does not
reproduce the environment: the whole Claude Code and Codex setup lives
in $HOME. New section 9 carries it - the three personal ~/.claude
files, the model-serving proxy mode and its refresh hook, the Codex
Databricks provider block and the five personal hooks that Omnigent's
generated hooks.json must merge with, the two secrets that have to
move out of band, and the transfer order.
Section 9.5 records the provider topology, which is easy to misread:
the global config's default provider is a Claude subscription, its
AIGW provider (the /ai-gateway/anthropic route, which is the Gateway
despite the path) is not default, and the worktree config is a
separate staging workspace. Measured with omnigent.gateway_inference:
global reports False for both families, the worktree True for both.
That measurement surfaced a real defect, now recorded in plan block
3f: the codex check reads the base URL Omnigent resolves, so a
kind: cli-config provider (which defers to the user's own
~/.codex/config.toml) yields None and is reported as not-backed rather
than unknown. False hides the Smart Routing option; unknown does not.
The rewrite must read the delegated config or report unknown.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* Trim routing PR: cut enforcement/telemetry/machinery, fix GLM effort + blank page
Wave-1 trim of the routing reference implementation, plus two live-caught
bug fixes and test trims from a parallel cleanup pass.
Cuts (per designs/PR_REWRITE_PLAN.md §3):
- Enforcement stack: canary, watcher, spawn-audit, warning banner,
session_warnings (3b). Hook generation + trust handshake kept.
- Routing telemetry: telemetry/routing.py, model_labels.py (3e).
- Fork-spawn exemption from the hook script (3d).
- Model-resolution machinery in smart_routing.py: MODEL_LISTS cost-ladder
(_cost_position, _ARM_SUBSTITUTES) replaced by a fixed per-family
fallback (claude->sonnet, gpt/glm->luna) + honest decline (3i). The
static infer_models catalog is kept: subagent_routing.py consumes it.
Fixes:
- GLM reasoning effort: GLM rejects xhigh; a routed GLM codex turn now
clamps effort to medium at every config-write and thread-settings point
(clamp_effort_for_model / effort_for_model_switch). Locked down in
tests/test_reasoning_effort.py.
- Blank-page crash: chipPendingBeforeRegion indexed past a shortened block
array on a stale cache (session switch / history reload), reading
undefined.type and unmounting ChatPage. Guarded + regression-tested.
Tests trimmed to the surviving surface; suites collect clean (2277) and
the core routing sets pass (266).
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* Remove unused `act` import left by the warning-banner test cut
The enforcement/banner cut removed the AppShell test cases that used
`act`, but left the import — oxlint (a pre-commit + CI gate) fails on it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* Substitute an unservable arm within its model tier before the family fallback
task_v1's frozen arms name a model *tier* (claude-opus-4-8 is the opus tier,
gpt-5-6-sol the sol tier), not a specific servable id. When the workspace
serves a different model of the same tier — claude-opus-5 for a
claude-opus-4-8 pick — that model is the arm the router meant, so
substitute_model now applies it (highest version within the tier) ahead of the
family fallback. Only when no same-tier model is servable does it fall to the
per-family fallback, then decline. Still no cost walk: an unservable pick never
slides down to a cheaper tier.
Adds _model_tier (the id's last alphabetic segment, None for a bare generation
id like gpt-5-5) and _version_key (numeric version, higher = newer) to rank
within a tier.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* Route unnamed codex subagent spawns on a placeholder instead of inheriting
Codex encrypts the spawn message, so an unnamed codex spawn carries no prompt
to route on. It previously fell through to allow-on-the-parent-model ("No
routable signal … inherits the session model"). Route it on a fixed
"Codex subagent task" placeholder instead, so it lands on the router's floor
arm rather than the parent's possibly-expensive model — matching ucode PR 251's
default_task_label. Precedence is unchanged: a real prompt (claude) wins, then
task_name/agent_name, then the placeholder.
Tradeoff, recorded honestly: every unnamed spawn scores the same placeholder
and so gets the same floor arm — a cheap sensible default, not per-spawn
routing. A named spawn still routes on its task_name; empirically that field
has been null on every observed codex spawn, so the placeholder is the whole
fix in practice. Per-prompt codex subagent routing is not reachable while the
message is encrypted.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: design plan for in-harness first-message routing (follow-up)
Route the main agent's model on the FIRST real user message via a
UserPromptSubmit hook + loopback callback (the route-subagent pattern),
so a bare `omni codex` / `omni claude` launch still routes, and web UI
and TUI share one mechanism. Marker = conv.model_override (authoritative,
existing cadence semantics) + a bridge-dir fast-skip file. Apply reuses
the verified composer forward path: thread/settings/update-then-turn/start
for codex, locked /model-injection-then-send-keys for claude
(block-and-replay). Cross-harness selection stays outside; create-time
routing stays for prompt-ful launches and composes via the marker.
Grounded in LIVE_MODEL_STATE.md probes and the official Claude Code hook
docs (block erases the prompt and injected input then proceeds; no hook
output can change the model; 30s synchronous timeout). Four spikes
ordered before any product code. Not part of the trim PR.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: record the conservative ruling on in-harness routing
Bryan's decision (2026-08-03): keep both paths. The server/create-time
path is the UI path and stays as the primary; the in-harness hook is
additive, covering only what the server cannot see (a prompt typed into
the TUI on a bare launch). One decision seam, three triggers, arbitrated
by model_override so exactly one fires per session. The outside path also
stays because it shares route_session_harness with cross-harness
selection - it is the cross-harness code, not a parallel implementation.
The maximal collapse (hook as sole trigger, CLI tier-2 entry machinery
deleted) is recorded as a deferred phase gated on determinism evidence
from the spikes plus live use, requiring an explicit go.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* Point a routed spawn at a tool the session actually has, and say why
The redirect told the model to "Use sys_session_send with args.harness=,
args.model=" — parameters that do not exist on the tool it holds. Those are
sys_session_send's named-spawn mode, which ToolManager only advertises for a
spec with declared sub-agents; the native harnesses declare none, so their
send tool exposes only {args, session_id} and the instruction was
unfollowable. Matrix row A-sub recorded the result: the model read the deny
and abandoned the spawn.
Name sys_session_create instead, which a spawn:True harness does hold (both
claude-native and codex-native set it) and whose schema really does take
model, message, and agent_id. Lead with the user's own choice to enable Smart
Routing and state that the sub-task is approved, so the deny reads as an
authorized re-route rather than a refusal, and close with the concrete call to
make. The same instruction now backs the deny branch when the verdict names a
model, instead of a bare "Spawn denied by Omnigent smart routing."
The redirect tests assert the properties that matter — denies, names the
routed model, names sys_session_create, never names sys_session_send — rather
than pinning the prose.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: register the bundle-agent and GLM-subagent CUJs (2.11, 2.12)
Two new surfaces enter the registry per Bryan. 2.11: Smart Routing on
bundle agents (debby/polly) reaches routing only through the gear
config's brain-harness override - a different code path from the native
Model row, previously untested; rows cover the menu render, the right
model/harness selection, and the live apply. 2.12: codex GLM subagents,
which ucode PR 251 explicitly skips; rows track the three blockers
(static candidates, placeholder floor-arm, and the effort wall) with
the sys_session_create child path recorded as already working.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): keep the bundle-agent harness row visible under Smart Routing
Two bugs in the debby/polly gear-config flow when Smart Routing is picked
as the brain harness:
- Picking Smart Routing unmounted the Agent Harness dropdown that made the
pick (it was gated on !autoRouting), leaving a lone locked Permissions
row with no way to read the pick back or switch away without Cancel.
The row now stays rendered, ordered above Permissions, and the gear
tooltip mirrors both rows.
- A remembered fully-auto pick had no degrade path when the server turns
smart routing off: the modal showed a blank harness select while the
create still sent harness_override "auto". The bundle flavor now drops
the pick quietly, matching the top-level auto-native rule, and keeps the
stored pick in case routing returns.
Adds 15 vitest cases on real debby/polly (claude-sdk) fixtures covering
menu shape, pick persistence, payloads, per-agent memory, and the
degrade; updates the one existing test that encoded the unmount bug.
NewChatDialog.test.tsx 228/228; shell suite 1754 pass; tsc/oxlint/
prettier clean.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: flip the §2.11 bundle-agent rows to vitest-backed
The gear-config menu bugs are fixed and covered (1f99705f); the two render
rows move to 🟡 pending a user eyeball, and the first-turn row records the
payload half as vitest-verified with the live end-to-end still owed.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: record the spawn-family policy in the GLM-subagent CUJ section
Subagent spawns stay within the parent harness family; GLM is
codex-family (all codex subagents may spawn gpt and glm arms when smart
routing is on); the auto harness alone spawns cross-family.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: let codex sessions spawn GLM subagents
GLM belongs to the codex spawn family: with smart routing on, every
codex spawn may target both the gpt arms and glm-5-2 (the auto harness
alone spawns cross-family; claude parents stay claude-only). Three
layers had to move:
- Catalog: databricks-glm-5-2 joins _CURRENT_GENERATION_MODELS[gpt], so
infer_models offers it and a routed glm pick resolves exactly instead
of substituting down to luna (this also removes the create-path C1
substitution arrow). Since no discovery listing ever advertises glm, a
live catalog row would still hide it — candidate_models now tops up
known-unadvertised arms for the gpt family only, nested spawns
included, without widening multi-model harnesses like pi.
- Vocabulary: codex's spawn_agent validates model ids client-side
against a closed enum of its own slugs, which silently killed EVERY
catalog-id rewrite, not just glm. New codex_model_vocabulary maps
catalog ids to codex slugs (databricks-gpt-5-6-luna -> gpt-5.6-luna)
and clamps spawn effort in agreement with clamp_effort_for_model; the
router hook rewrites through it and falls open when no slug exists.
- Catalog file: glm has no codex slug at all, so the executor reads the
installed CLI's own catalog (codex debug models, cached per binary and
CODEX_HOME per host process) and writes the session's private
model_catalog_json with a glm entry cloned from the cheapest arm,
carrying its own low/medium/high effort ladder — codex then clamps an
inherited xhigh instead of refusing the spawn. Every failure path
leaves codex on its bundled catalog.
Live-proven on the local stack: a native spawn_agent glm subagent off an
xhigh codex parent ran at system.ai.glm-5-2/medium and completed, with a
luna sibling in the same turn unaffected. Family policy pinned by tests
in both directions and both modes.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: give codex spawn routing a real signal and honor explicit asks
Live verification exposed that no codex spawn could ever land glm even
with it offered: this codex's spawn_agent has no task-name field, the
spawn message was withheld from the router on a disproven encryption
premise, and an explicit model in the spawn arguments was overridden by
the placeholder-scored default. Every spawn therefore routed on the
19-char placeholder and landed the default arm (verified live: three
spawns, including one explicitly asking for system.ai.glm-5-2, all ran
gpt-5.6-sol).
- The codex hook now forwards the spawn message (plaintext in hook
payloads — measured) as the routing prompt via a new prompt_keys seam,
so the router scores the actual task and can pick delegate arms.
- The hook also forwards an explicit spawn model as requested_model. The
server honors the ask when it is an arm the spawn's own harness could
have been routed to (bare-arm match, so any spelling lands the
servable one); a cross-family or unoffered ask is routed over and
recorded truthfully as attempted_override. The honor is restricted to
the requesting harness's candidate row because a rewrite runs
in-place — an auto-harness session must not hand codex a claude arm.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: carry requested_model across the runner relay hop
The relay resolver rebuilds the route-subagent body field by field, so
the new requested_model never reached the server: live, a spawn that
explicitly asked for system.ai.glm-5-2 was routed to luna with no
attempted_override recorded. The relay test now pins every routing
input surviving the hop.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: close the §2.12 GLM-subagent rows with live evidence
All four layers verified on the shipping path 2026-08-04: glm in the
live spawn menus, exact in-family resolution, and a live glm subagent
(turn_context system.ai.glm-5-2/medium off an xhigh parent). Records the
two extra layers live testing surfaced: message-as-signal (spawn_agent
has no task-name field here) and honoring explicit in-family model asks.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): scope a bundle agent's Smart Routing brain to that agent
Picking Smart Routing as Debby/Polly's brain-harness renamed the whole
composer selection — chip, tooltip, and modal title all flipped to
"Smart Routing" as if the top-level auto harness had been picked, and
re-clicking the agent's own row silently dropped the brain. The two
flavors share no state (auto vs auto-native sentinels, per-agent
memory), but the derived autoRoutingSelected union was used for
identity, not just row gating.
Identity readers (agentLabel, triggerTooltip, configSummary, modal
title) now key on smartRoutingHarnessSelected alone; the union keeps
its one honest reader (the routing-seed skip) and a comment stating the
rule. The bundle modal shows the Agent Harness row alone (locked
Permissions belongs to the top-level flavor whose creates actually send
permission fields), the permission-reset effect and handleSelectAgent
key on the top-level sentinel only, and create payloads are
byte-identical in all four flavor combinations.
Tests: 292 pass across the three NewChatDialog suites — includes a new
"Smart Routing flavors are scoped separately" describe (mixed fixture)
pinning both leak directions, plain-create isolation, and the brain
surviving a re-pick; the old chip test that encoded the leak now pins
the fix; the two locked-Permissions tests moved to the top-level
flavor's describe. tsc/oxlint/prettier clean.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: add CUJ_MASTER.md, the consolidated routing CUJ registry
One doc merging the full CUJ_STATUS registry (matrix, recipes, tiers,
all section areas), the v4 in-harness routing phases (phase 1 landed
with evidence; phase 2 blockers), tonight's six live-feedback rows, and
a new adversarial section: 23 Breakage CUJs (X1-X23) grounding how this
setup fails for other people — missing/old CLIs, non-AIGW credentials,
router timeouts vs the hook ladder, hook-merge precedence, shared
bridge roots across worktrees, and the static glm fallback offering an
arm a workspace may not serve. Includes stack bring-up with a pinned
random-port convention, the R11 bare-launch recipe, a 112-row registry,
and a revisit list split by needs-human vs headless.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): honest subagent-routing display — fresh reads and gated chips
The gear modal's Subagent routing row could show Inherit while "on"
was stored: the override hydrates only at session bind (no SSE event
carries it, the session query never refetches), and the modal seeded
its draft once per open — so the row displayed a stale value and Save
could PATCH a value the user never picked. The row now holds a pick
that reads through to the live store value until touched, save() writes
only a pick that still differs from a fresh store read, opening the
gear re-reads the two override switches (refreshSessionOverrides — slim
snapshot only, so it cannot trigger the sticky-model PATCH), and a
session switch under an open modal re-seeds instead of writing the old
session's drafts onto the new one.
Per the user's ruling, native_subagent routing chips now render only
when the override is explicitly "on": on Inherit (or off) the chip
would advertise a setting the user didn't choose. Display gate only —
the decision rows stay persisted as the audit trail, and an inheriting
session's spawns are still routed server-side. Flip-side caveat,
deliberate: toggling the setting retro-hides/reveals historical chips.
453 tests pass across the three touched suites (display/write matrix,
stale-under-open-modal regression proven failing pre-fix, chip-gate
scope table); tsc/oxlint/prettier clean.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test(web): unit-cover the sub-agent routing chip gate
Pins stripGatedSubagentRoutingChips at the unit level alongside the
composer-level coverage: explicit "on" keeps spawn chips, Inherit hides
them while the session's own (and legacy scope-less) decisions stay.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: gate Smart Routing per harness on AI-Gateway backing
A harness whose CLI runs off a personal subscription (ChatGPT codex,
Bedrock claude) cannot run a routed pick — routing rewrites the launch
model to a gateway catalog id. Verified across four mocked credential
states (neither/claude-only/codex-only/both backed) and closed the
holes where routing could still be reached:
- gateway_inference: gateway_inference_state / not_gateway_backed read
a host's reported map under any harness spelling; unknown (older
host, unevaluable family) never gates.
- server create: the auto path refuses to route when either arm is
unbacked (no safe half-menu — the pick lands after the create
commits), and an explicit routing-on create pinned to an unbacked
native harness 400s with the way out named, instead of minting a
session whose routing silently never applies. Children and subagent
sessions stay with their parents' spawn/turn gates.
- CLI preflight: --smart-routing consulted only the server's host row
and silently proceeded when no host had registered — pinning a
databricks model onto a ChatGPT-backed pane. The launch always runs
on this machine, so the local gateway-inference map is now the
authoritative first gate, with the host row as fallback; the two
failure modes get distinct messages (no routing model configured vs
not AI-Gateway-backed).
328 tests pass across the CLI/gateway/create/routing suites, including
a parametrized A-D truth table over both arms and the auto route.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): require gateway backing for the bundle-agent Smart Routing brain
The Debby/Polly Agent Harness menu offered Smart Routing whenever the
server flag was on, even when this host backs only one model family
with the AI Gateway — the router could then land the session's work on
an arm that cannot run its routed model (a codex pane on a ChatGPT
subscription). The auto option now requires both families
gateway-backed, mirroring the server-side create gate. Gateway backing
only: unlike the top-level harness row, the bundle brain routes across
SDK harnesses, so native wrappers/CLIs are deliberately not required.
The gate drops only the OPTIONS entry — membership checks and the
summary label for an existing pick keep the unfiltered map, so a saved
pick still reads back honestly.
235 NewChatDialog tests pass, including the new offers/hides matrix per
gateway state; tsc/oxlint/prettier clean.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: make cross-harness spawn redirects actionable in native sessions
An auto-harness claude session's redirected spawn was denied with an
instruction naming sys_session_create — a tool the model could not find
(claude spells MCP tools mcp__omnigent__<tool>, schemas are deferred
behind tool search, and no allowlist pre-approved them), so it treated
the deny reason as prompt injection and refused. The omnigent MCP was
attached all along; the actuation was unreachable.
- The deny/redirect reason now names the requesting harness's own
spelling (claude: mcp__omnigent__sys_session_create; codex: the bare
name plus its omnigent.<tool> display form — verified empirically
against codex-cli 0.145: the flattened omnigentsys_session_create is
log-only and not callable), notes the tools come from the attached
omnigent server and may need a tool search, and degrades gracefully —
when the session's relay does not advertise the spawn tool, it tells
the model to do the sub-task itself instead of naming a tool that is
not there.
- Auto-harness claude launches (label or harness_override 'auto', both
metadata loaders) add --append-system-prompt with the routing note and
an --allowedTools list of the four redirect-loop tools
(sys_session_create/sys_agent_list/sys_session_send/sys_read_inbox —
the inbox read was live-proven required to close the loop); pinned
launches stay byte-identical, pinned sessions never see redirects.
- Auto-harness codex launches get the note as developer_instructions
(through the reversible sidecar sync) and per-tool
approval_mode=approve tables in the generated mcp_servers section.
Live-proven on the incident's exact shape: auto-harness claude parent,
spawn redirected to gpt-5-6-sol/codex-native, model called
sys_agent_list then sys_session_create, child session created on the
codex arm with parent linkage, result returned via the inbox, parent
reported it. Control session (pinned) carried neither flag. 229 tests
pass across the five touched suites.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: record the e2e sweep's evidence across the CUJ_MASTER registry
Overnight sweep on both stacks: 9/9 create matrix exact (the C1 glm
arrow is gone), GLM subagent rows live-proven including the effort
clamp firing, cross-harness redirect actuation end to end, codex
bare-launch 8/8 including crash durability, gating row 65 closed live,
1,627 pytest + 1,446 vitest with only the two accepted baseline
failures. Registry corrections from false greens the sweep caught:
deleting the routing block does not disable routing (only
provider:none does), the audit/canary rows are unreproducible since the
machinery was cut, the turn-path fail-open is silent, and several
recipe spellings fixed.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: switch claude models via the picker, never the global-default arg form
Every routed claude-native switch (and the web model picker) typed
'/model <arg>' + Enter into the pane — claude's arg form saves that
model as the user's GLOBAL default in ~/.claude/settings.json, caught
live rewriting the file during the e2e sweep. Ported the v4 actuator:
inject_model_selection submits bare /model, polls for the picker, walks
the cursor onto the target row, and presses 's' (session-only — proven
to leave the file byte-identical; Enter and digit keys both save the
default and are never sent), resolving exact catalog-id matches across
all rows before any alias match so a workspace serving two generations
of one tier lands the right row. auto_confirm's fixed 0.3s sleep is
now a dialog poll with a deadline.
The web path needed more than the executor's targets, caught live: the
picker dropdown sends tier ids, and this workspace serves two Opus
generations — 'opus' alias-matched the wrong row and the custom slot
(labelled by display name) was unreachable. Targets now come from the
session's resolved launch-config env (alias pins + custom slot + slot
name) merged under the bridge record; both cases verified live
('opus' -> Opus 4.8, the custom tier -> Opus 5, each session-only).
Live proof on the running stack, no restart (runners spawn per session
from disk): a routed opus-5 -> sonnet-5 switch and two web switches,
panes showing bare /model + 'for this session only', zero 'saved as
your default' lines in full scrollback, and ~/.claude/settings.json
md5-identical throughout. 640 tests pass across the seven touched
suites, including a tripwire that fails if the arg form ever returns.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: let a spec hand its brain harness to Smart Routing
A spec that pins executor.config.harness also pins the family its
sub-agents are routed within, so a two-headed agent loses the head that
lives in the other family: debby's `gpt` sub-agent, declared on codex,
was rerouted onto claude-sdk and both heads answered as Claude.
Add executor.config.smart_routing_harness: auto, which opts a spec out of
its own pin for a Smart Routing session and converges on the "auto"
sentinel path the brain-harness picker already offers by hand. Gated to
Smart Routing creates only, and never over a client's explicit harness or
model pick, so a spec carrying the key is inert with routing off.
Set it on debby and polly, whose sub-agents span harness families.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: two-state subagent routing, stamped at create — Inherit is gone
Per the user's ruling: a session that starts with Smart Routing routes
the subagents it spawns; everything else is Default, meaning whatever
the harness natively does. The tri-state inherit (unset resolving to
the session's own cost-control state) produced displays the user never
picked and a chip gate that disagreed with behavior.
- subagent_routing_enabled is now exactly override == "on"; the spawn
gate reads one explicit switch instead of re-deriving parent state.
- The server create handler stamps "on" once, for every path that
starts routed: top-level auto harness, bundle-agent auto brain, fixed
native harness with routing on, CLI --smart-routing (including v4's
bare in-harness creates, which send cost_control on), and children of
a routed parent. Unrouted creates store nothing; an explicit caller
value always wins; only "on" is ever stamped so ordinary creates
cost no extra write.
- One-time data migration stamps "on" onto existing rows exactly
where the old inherit rule resolved to routed (146 of 158 live rows),
so sessions in flight keep routing their spawns across the deploy;
downgrade is a documented no-op.
- The gear row offers exactly two options — Smart Routing / Default —
reading through to the stored value; a legacy null displays Default
and re-picking it writes nothing. PATCH keeps accepting explicit null
as an API-level clear; the UI never sends it. The chip gate's logic
is unchanged and is now an exact mirror of behavior.
181 python + 642 web tests pass across the touched suites (stamp
matrix, migration up/down, two-option UI, PATCH back-compat);
tsc/oxlint/prettier and ruff clean.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: the router always decides a requested-model spawn — honor only on match
A spawn naming a model bypassed routing entirely ('honored — it is a
routable arm'), so the parent model's habit of writing a model field
starved the delegate arms: a dry-run subtask that the router scores to
glm ran on sol because the router was never asked. Per the user's
ruling, the requested model never short-circuits: the router is always
called, 'honored' appears only when its pick matches the ask (bare-id
normalized, [1m] folded), and a mismatch applies the router's pick with
the ask recorded as attempted_override — struck through on the chip
next to the applied model — and named in the codex parent's notice so
it does not silently re-spawn.
Claude-side asks now resolve through the session's alias pins before
comparison (a bare 'opus' never matched its own pinned arm and logged a
spurious override on every named spawn); inherit/default sentinels
carry no ask. The sys_session_send path's raw string compare gets the
same normalizer (a servable-alias respelling is not an override). On
router outage the spawn still runs on the ask (fail-open unchanged)
and the record now says so.
Accepted cost, signed off: an explicit ask — including a user-authored
'use glm' — is honored only when the router independently lands the
same arm; task_v1 exposes no requested-model input (live-probed: config
hints ignored, narrowed menus rejected). Follow-ups if wanted: a
requested_model field in the routing proto, or a session-level pin.
197 python + 40 web tests across the touched suites; live-probed
against the real router with match, mismatch, and no-ask shapes.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: session Smart Routing is a create-time choice; the gear keeps one knob
Custom/SDK agents (Polly, Debby, and any non-native agent session)
lose the in-session Smart Routing toggle. It was already a near-no-op
for the session's own turns — the first routed turn pins
model_override, after which the toggle changed nothing — and its only
live effect was gating child spawns through a field the visible
Subagent routing row did not control. Per the user's ruling, Smart
Routing for a session's own turns happens once, at session start.
The Subagent routing row (identical copy, options, and testids to
native sessions) is now the single in-session routing control, and the
three server-side child-spawn gates (_force_auto_for_child, the SDK and
native parent-routing turn gates) plus the child create-stamp's parent
clause read the subagent-routing switch instead of parent cost-control.
Behavior-identical for every existing row via the create-stamp and the
e6f7a8b9c0d1 backfill (live DB verified: zero stranded cc-on/sr-unset
rows) — and picking Default now genuinely stops a bundle's spawns from
being routed, which the old pair of knobs never delivered.
isSubagentRoutingSession widens to all non-native top-level agent
sessions (their spawns go through the create path, which is
harness-independent), closing the pi-brain gap where the row vanished
mid-session. The gear tooltip drops its standalone Smart Routing line,
matching native.
189 python + 293 web tests across the touched suites, including
gate-flip cases proven to fail against the reverted server edits; full
web suite unchanged at 5005 passing.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* spike: codex UserPromptSubmit routing probe (S1/S2 scaffolding)
A marker-gated spike-userprompt subcommand on the codex policy hook:
logs every UserPromptSubmit payload to the bridge dir, and (behind a
one-shot marker file) fires thread/settings/update on the live thread
via the app-server websocket, optionally blocking the prompt. Inert
without the marker files. Kept as the working reference for the real
route-turn hook: the ws:// client framing, the second-command-per-event
wiring, and the trusted-module trick are all proven here.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: record the spike verdicts - Variant B disproven, Variant A verified
S1 FAIL, 3 runs with a bogus-model positive control: codex binds the
turn model at turn/start and writes turn_context before UserPromptSubmit
runs, so an in-window thread/settings/update only lands on the NEXT
turn. Variant A (block -> settings update -> replay) was then verified
end-to-end on codex: clean 1.08s abort, routed turn_context on the
replay, re-entrancy marker held, and the forwarder self-pins
model_override off thread_settings_applied.
S2 PASS: UserPromptSubmit fires for turn/start RPC turns with payloads
byte-identical to TUI-typed input; payload carries prompt + LIVE model
+ codex thread id (not the omnigent session id). S4 PASS: full hook
chain 0.37-0.78s; the settings call 26-77ms, wide margin under the 30s
budget. New trap recorded: never read the live model from config.toml
(stale on every read during the spike); take it from the hook payload.
S3 (claude block-and-replay UX) is the only spike still open.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* spike: claude UserPromptSubmit block-and-replay probe (S3 scaffolding)
Marker-gated spike-userprompt subcommand on the claude policy hook plus a
second UserPromptSubmit command in the bridge's settings generation. Inert
without the marker file. Kept as the working reference for the real
route-turn hook on claude: it is what proved the block leaves a clean
slate and the bracketed-paste replay is byte-exact.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: S3 passes - claude block-and-replay verified, all spikes closed
Block is cleaner than documented: input erased, reason shown, and nothing
persists (transcript logs only an informational preventContinuation row -
no user row, no model call; the omnigent conversation records nothing for
the blocked prompt). Replay is byte-exact including a real multi-line
prompt, submitted as one turn by the existing bracketed-paste injector.
The replay's fresh UserPromptSubmit no-ops on the consumed marker, and
/model does not fire UserPromptSubmit so the switch cannot self-trigger.
Three routed turns landed three different arms. Visible gap ~3-4s, the
/model settle dominating. No turn-2 fallback needed.
Records the actuator spec (poll for the Switch model? dialog, settle on
context.json - never fixed sleeps) and four claude-specific findings: the
hook payload has no model field, /model <arg> rewrites the user's GLOBAL
default (product blocker for the actuator, needs a decision), the /model
echo can make a weak model refuse the replayed prompt, and this
deployment's /model vocabulary is full catalog ids rather than bare
aliases. Also flags a pre-existing defect that bites the current branch
independently: inject_slash_command(auto_confirm=True) confirms the switch
dialog after a fixed 0.3s sleep, but the dialog took 1.861s with cached
history - the Enter is dropped and the next injection times out.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: in-harness first-message routing for codex (phase 1)
A bare 'omni codex' launch now routes on its first prompt, wherever that
prompt comes from (TUI-typed or RPC-delivered) — the spike-verified
block-and-replay variant, productionized:
- omnigent/runner/turn_routing.py: the decision seam (wire types, the
route-once policy, loopback relay with advertisement + live-pid check,
and the runner-side replay that waits on the hook's done-marker and the
blocked turn clearing before redelivering through the normal events
path, which re-checks the gate and records no second decision).
- codex hook 'route-turn' subcommand: fast-skip on the marker, POST to
the loopback, thread/settings/update + config mirror, then block.
- POST /v1/sessions/{id}/hooks/route-turn mirroring route-subagent,
reusing route_turn / catalog / decision-chip plumbing.
- Registered as a second UserPromptSubmit command in the trusted policy
hook module; started/torn down beside the subagent router at launch.
- write_advertisement/read_router_endpoint gain a filename kwarg so the
loopback plumbing is shared with subagent routing, not copied.
The route-once gate is the routing-decision label, not model_override:
the codex forwarder mirrors config.toml's stale model into
model_override at the first turn/started, beating the hook, so presence
can't distinguish a real pin from the mirror. Residual gap (documented
in already_routed): a manual pin with Smart Routing on gets hook-routed
once; closing it needs pin provenance, left for phase 2.
Live-verified on the :64688 stack: trivial->luna, sprawling->sol, one
decision row and one user turn each; second turn fast-skips with zero
network. Spike scaffolding (spike-userprompt) removed. 96+69 tests pass
under the sanitized env run.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: make the blocked first prompt durable across runner crashes
Between the hook's block and the replay delivery the prompt existed
only as an in-memory asyncio task — a runner crash in that window lost
it forever while the decision chip, model_override pin, and done-marker
all said routing succeeded (exactly the dead-session shape reported
from live testing, reproduced with a SIGKILL at the marker write).
The relay resolver now writes turn_replay_pending.json before handing
the verdict back (on disk before the hook can block), clears it on
delivery or when the hook is known to have fallen open, and keeps it on
a failed delivery. On the next launch schedule_pending_replay_recovery
drains a leftover record: it requires the marker (proof the hook
blocked), waits for the relaunched thread, and only delivers after
confirming via the item history that the prompt never ran — an
unreadable session leaves the record for a later launch rather than
risking a double-run. A session_id match guards forks sharing a bridge
dir. Adds a turn_routing.log hook trace for diagnosability.
Live-proven on the spike stack: four fresh sessions routed on their
first prompt with turn-2 fast-skips, plus a crash-recovery run
(SIGKILL at the marker; relaunch recovered and replayed the prompt on
the routed model, record cleared). Investigation of the reported dead
sessions showed no prompt ever reached them (no UserPromptSubmit, no
events, empty rollouts) — the durability gap was the adjacent real
defect. 101 tests pass across the turn-routing and codex hook suites.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: in-harness first-message routing for claude (phase 2)
A bare 'omni claude --smart-routing' launch now routes on its first
typed prompt, mirroring codex phase 1 through the same turn_routing
seam: claude-native joins _TURN_HOOK_HARNESSES, the claude hook gains a
route-turn subcommand (marker fast-skip, loopback POST with the live
model read from context.json, block), and the runner performs the model
switch inside the replay via _apply_routed_model — the composer gate
only forwards model_override in-band when it just routed, so a
hook-routed replay previously arrived with no model and ran on the
launch model.
The switch actuator drives the /model PICKER instead of '/model <arg>':
sandbox-proven that the arg form saves the pick as the user's GLOBAL
default in settings.json, while walking the picker with arrows and
pressing 's' switches 'for this session only' with the file
md5-identical across idle soak and clean exit (digit keys also save the
default and are never sent). inject_model_selection resolves exact
catalog-id matches across all rows before any alias match — a workspace
serving two opus generations otherwise lands the wrong row. The routed
composer path switches through the same picker, closing the global
default rewrite on every routed turn; auto_confirm's fixed sleep is
replaced by a dialog poll with a deadline.
CLI: --smart-routing without -p now creates the bare routed session
(cost_control on, no create-time route) and launches the TUI for
harnesses with in-harness routing; auto/no-harness still requires -p.
Spike scaffolding (spike-userprompt) deleted.
Live-proven on an isolated stack: five bare claude launches, trivial
prompts routing to sonnet-5 and a narrow task escalating to opus-4-8
(the pane held opus-4-8 AND opus-5 rows — the id-first matcher picked
right), one decision and one user message each, second prompts
fast-skipping with zero network, and ~/.claude/settings.json
md5-unchanged after every run. 364 tests pass across the touched
suites.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: drop the vestigial turn_router_dir kwarg that broke claude launches
A merge-resolution leftover passed turn_router_dir to
augment_claude_args, whose merged signature never gained the parameter
(the claude route-turn hook registers via bridge_dir and self-gates on
the advertisement at fire time) — every claude-native launch on this
branch died with a TypeError before the pane existed. Caught by the e2e
sweep's bare-launch row.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: apply the routed model to the codex thread in codex's own slug
The route-turn actuator sent thread/settings/update the raw catalog id
(databricks-gpt-5-6-luna). The turn ran — the gateway serves the id —
but codex has no catalog metadata for that spelling, so the pane warned
'Model metadata not found, defaulting to fallback' and /model kept
highlighting the launch slug, which reads as routing not working.
New codex_model_vocabulary (shaped like claude_model_vocabulary):
comparable_model_id folds catalog prefixes, the [1m] suffix, and
dot/dash spelling; codex_model_slug resolves the routed id against
codex's live model/list rows, so codex stays the vocabulary authority
with no hardcoded table. The actuator lists models on the client it
already holds, sends the matched slug, and mirrors the same spelling
into config.toml so the forwarder cannot flip-flop between spellings;
model/list failure or an unmatched id falls back to the id verbatim.
The decision row keeps the catalog id.
Live-proven: thread_settings_applied carries gpt-5.6-luna, zero
catalog-id spellings in the rollout, /model shows the routed row as
(current), no metadata warning for the routed model, one decision,
turn-2 fast-skip. 122 tests across the four touched suites.
Known siblings left for follow-up: thread/start still passes the
catalog id (the remaining launch-model metadata warning), and the
codex spawn path injects catalog ids verbatim.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* feat: gateway backing selects the router; the chip discloses the source
Gateway inference stops being a hide gate and becomes a source
selector. Every Smart Routing surface stays available; the AIGW
conditions decide which router answers each decision: the external
task_v1 client when it is configured and every family the decision
involves is AI-Gateway-backed, else the built-in judge
(LLMRoutingClient) when the server has one, else today's errors —
now reworded to name the real neither-source cause.
- New routing_backend seam: RoutingBackends holds both clients;
select_router picks per decision; caps carry both (routing_client
stays the primary for un-migrated readers). The CLI builds both, so
a Databricks deployment keeps its judge as the fallback.
- Off-gateway decisions never see the static databricks-* tables:
allow_static_fallback gates the infer_models fallback/top-up, and the
route declines rather than offer an id the pane cannot run (the two
hazard tests pin this seam-first).
- Decisions persist router_source ('databricks-aigw' | 'oss-llm');
/v1/info exposes smart_routing_sources; older servers degrade to
both-mirror-smart_routing_enabled in the CLI and web alike.
- The chip carries a small Databricks mark only when the AI Gateway
router answered ('Routed by the Databricks AI Gateway'); OSS and
legacy rows carry none; pickers are never branded.
- CLI preflight on an off-gateway family with a judge available prints
one informational downgrade line and proceeds instead of erroring.
- Setup doc and routing overview updated to the source-table semantics.
696 python + 336 web tests across the touched suites (21-test selector
truth table, the create-refusal splits, the /v1/info matrix, badge
render cases); ruff/tsc/oxlint/prettier clean. The 9 wider-run
failures are pre-existing snapshot-cache pollution, reproduced
identically on the clean parent.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: apply the routing test-suite overhaul and refresh the CUJ registry
Registry (designs/CUJ_MASTER.md): 4 rows + 1 recipe cut as fixed or
contradicted; the spawn-audit/canary rows retired-with-reason (the
machinery went with 484f7300 — deliberately out of scope, named in the
PR); row 95 re-entered as a picker regression row; ~22 rows updated to
today's ground truth (codex slug comparisons via comparable_model_id,
strict adherence, the spec-declared auto brain, the deleted standalone
toggle, source-selector semantics); 19 new rows in area O covering the
create-stamp matrix through the off-gateway static-menu decline.
Suites: the turn-gate tests renamed test_turn_routing_enabled_* so they
stop reading as the two-state spawn gate; the matching-ask pair and
five integration duplicates folded into their parametrized seam tests
with per-item duplication proof (122 -> 119 cases, no coverage lost).
25 new targeted cases: an AST-based guard module pinning that no claude
routing path builds '/model <arg>', the switch path holds no fixed
sleeps, the picker reads only the user settings file, and cursor/kiro
remain the only (documented) arg-form senders; hook-settings cases
pinning both routing hooks' timeouts above their script budgets and
coexistence with the policy hooks; the turn-routing timeout ladder
strictly decreasing and the router client inside the hook budget; the
two-concurrent-first-prompts and manual-pin-routed-once gaps pinned as
recorded decisions; migration edge cases (unparseable blobs, dangling
parents, idempotent re-upgrade).
744 + 364 + 192 sanitized pytest passes across the routing slice; web
suites re-confirmed green as baseline; ruff and pre-commit clean.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: add the e2e routing CUJ suite behind a mocked router
Five end-to-end CUJs — claude and codex from session start (API) and
from a typed first message (TUI), plus the auto-harness cross-family
redirect — each asserting the routing artifacts (decision rows and
their router_source, the pinned model, marker files, thread settings in
codex's own slug, pane state, message counts) and never answer content.
Two properties make it CI-shaped. The routing API is mocked: a
deterministic routes:select service replays the live router's own rule
traces (trivial -> cheapest arm, delegate-class -> glm, crosscutting ->
default/escalate) and keeps the real contract honest by rejecting a
narrowed menu exactly as staging does — proven against the real
ExternalRoutingClient over HTTP, not a hand-written body. And subagent
spawns are asserted as issued-and-routed rather than awaited, so no
test waits on a child's output or an inbox return.
21 pass in ~5 minutes; the suite is opt-in (smart_routing marker plus
OMNIGENT_E2E_SMART_ROUTING=1) and skips with a named reason when the
CLIs, tmux, or a provider config are absent. Each test boots its own
ephemeral server, host, temp DB and temp config home; the developer's
settings files are left untouched, which CUJs 1/3/5 assert by digest.
The CLIs are launched with their trust-bypass flag through
terminal_launch_args (the pattern tests/e2e/test_comment_tools_claude_native.py
already uses) because a fresh temp workspace otherwise blocks the input
box on a trust dialog before any hook can fire.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* chore: remove development-session scaffolding from the PR
Working docs (CUJ registries, plan documents, session setup notes),
the personal dev scripts (dev-env/run-server/run-host/run-frontend and
the routing-API probe), and their allowlist rows were session tooling,
not product: several named internal staging workspaces and proxy
endpoints, and none of them belong in a public repo. A test fixture's
profile string is generified for the same reason. The user-facing
routing documentation moves to the omnigent-site docs (PR #446 there).
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: settle the rebase against main's session-routes and model-picker work
Main split the session routes into explicit imports and grew a
host-resolved Codex launch-model catalog while this branch was out; the
replay needed both re-applied by hand.
- Import the names the routing paths use explicitly (`_logger`,
`_get_runner_client`, `_spawn_gateway_backed`, the validators) now that
`routes_hooks` / `routes_core` no longer star-import them.
- Keep the pre-existing `native_policy_not_enforced` banner: the trim
commit dropped its server half, but the runner still reports the
degrade reason, and main re-exports the helpers.
- Codex's Model row now carries the host's real catalog alongside the
Smart Routing sentinel instead of replacing it, with the resolved
default label back via a `defaultLabel` prop on `RoutingModelSelect`.
- Refresh the tests those two changes made stale, and re-apply the hook
timeout the dropped merge commits had fixed in place.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: apply the external-review fixes and drop both new migrations
- Turn dedup compares decoded user-message text, not a JSON dump
- A no-op model pick is terminal: pinned and recorded without replay
- Child sessions route once; follow-ups cannot flip harness_override
- The turn marker is scoped to {session, decision}; the claude hook
reads the live session id, so /clear cannot reuse a stale marker
- Hook relays require LEVEL_EDIT; rationales log at DEBUG
- The turn router registers only when routing is enabled; codex model
catalog population runs off the event loop with a 60s failure TTL;
hook timeouts sit 10s above the inner HTTP timeout
- gateway_inference moves off the hosts table onto the host connect
handshake, held in server memory (unknown-is-backed until a host
re-reports); both alembic migrations are deleted — the PR adds zero
migrations
- Routing availability checks unified on the routing_backend helpers
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: gate the router's ambient-credential tests on the databricks extra
The new ambient workspace-credential tests patch
``databricks.sdk.config.Config``, but ``tests/server`` runs on a lean CI
lane that neither installs the ``databricks`` extra nor deselects marked
tests, so all eight failed collection with ``ModuleNotFoundError: No
module named 'databricks'``.
Mark them the way the repo already gates SDK-coupled tests, and list
``tests/server/test_smart_routing.py`` on the databricks lane — a marked
test in a path that lane does not cover would otherwise run nowhere.
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* revert: switch claude models with `/model <id>`, not the picker
Switching a live claude-native pane through Claude Code's interactive
`/model` picker took ~530 lines of tmux screen-scraping to avoid one side
effect: the argument form also saves the pick as the person's global
default in `~/.claude/settings.json`. The repo owner has accepted that
write, and an external review found the picker path fragile in ways the
argument form has no equivalent of — a 5s server forward budget against a
~35s worst-case automation whose result was discarded, an applied-check
that could return before the ~1.9s "Switch model?" dialog rendered, a
next-message-swallowed-by-dialog hazard, no busy-pane gate, no scroll
handling, and no concurrency lock.
So every claude model-switch call site goes back to injecting the text
`/model <id>` plus Enter through `inject_slash_command`, with
`auto_confirm=True` so the cache-invalidation dialog is still answered:
- the web/API `model_change` endpoint (`runner/app.py`),
- the first-message turn-routing switch (`runner/turn_routing.py`),
- the per-turn executor switch (`inner/claude_native_executor.py`).
Fail-open semantics are unchanged: a failed injection is logged and the
turn still runs on the pane's current model.
Deleted with their last caller: `inject_model_selection`, the picker's
open/apply poll ladders, the row regex and row scanner, the row-matching
and row-picking helpers, the session-only key, and the two runner-side
target-spelling resolvers. Kept: `inject_slash_command` and the polling
`_confirm_tui_dialog` (shared with `/effort`, and a real improvement over
the fixed 0.3s sleep it replaced), plus a single picker-footer string the
pane-readiness gate uses to notice a picker the person opened by hand.
The AST guards that forbade the argument form are gone; the "omnigent
never writes the user's settings file" and "no fixed sleeps on the switch
path" guards stay, since both still guard live code. The e2e settings
guard now compares everything in `~/.claude/settings.json` except the
`model` key Claude Code itself moves.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): one routing chip per pick, hydrate the gear modal's Model row
A Smart Routing create routes twice: once at create time (recorded as a
`session`-scope chip) and again on the session's first turn (a `turn`-scope
chip). Both land before the user's message, both resolve to the same model and
harness, and both render the identical "Smart routing · applied · claude-native"
card — one above the message, one below. The transcript opened on a duplicate.
Collapse them in the block walker: a `session` chip whose next content block is
a `turn` chip with the same model, harness, applied flag, and agent renders
nothing, and the turn chip (the one that pairs below the message) stands for the
pair. Both rows stay persisted as the audit trail, and a create-time pick the
turn CHANGES — or a failed create-time route, recorded as an unapplied
`"unavailable"` row — still renders its own chip, because those two chips say
different things.
Also in the gear modal, the Model row rendered blank on a routed session.
Routing pins the router's fully-qualified pick (`databricks-claude-opus-4-8`),
which the harness catalog carries only under an alias (`opus`) — so no option
declared the Select's value and Radix fell back to its empty placeholder. The
live model now rides as its own option, labelled exactly as the status label
below the composer. An untouched row still submits nothing: `save` re-pins only
a draft that actually changed.
Three review findings:
- `useSession` asks for `refresh_state=true` on every fetch again. Narrowing it
to the cache-cold fetch meant an invalidation refetch — how switching a
session's agent reloads the snapshot — came back off the runner's process
cache, leaving the PREVIOUS agent's model catalog on screen until a hard
reload.
- Drop the 30s snapshot poll every open session ran. Its only consumer was the
session warning banner, which the enforcement-stack trim removed; nothing
reads a field the poll refreshes, so the poll and its opt-in options go with
it. That also makes the unconditional refresh above safe — nothing re-asks
often enough to thrash the runner's caches.
- `refreshSessionOverrides` no longer fetches through the query client. It reads
two plain DB columns, but writing the reply into the shared `["session", id]`
cache replaced every other surface's refreshed snapshot with an unrefreshed
one, dropping the `model_options` the model picker renders from.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: scope the codex routing extras to the sessions that need them
Three session classes now decide what a codex home carries: a plain
session gets a byte-identical pre-routing home (bundled catalog,
symlinked hooks.json, no spawn gate, no extra tool approvals); a
pinned-harness Smart Routing session adds only the extended model
catalog; an auto-harness session that routes to codex adds the spawn
gate and the cross-session tool approvals. The subagent router
endpoint starts only where something consumes it. The catalog probe
validates its payload and holds a lock across concurrent boots.
Dispatch validation accepts gpt substrings again and localizes
glm/kimi ids mechanically. The codex env filter now lets the router
and catalog launch signals through — the SDK-codex hook path was
silently dead without them.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: keep pinned codex launches free of routed-spawn extras
The runner passed `developer_instructions` to `build_codex_native_server`
for every codex terminal (with a `None` value on pinned sessions), which
changed the launch call shape for sessions Smart Routing does not own.
Pass the kwarg only for auto-harness sessions.
The claude-native launch-args tests handed a raw `tmp_path` to
`augment_claude_args`, which validates the bridge dir against the real
bridge root; point the bridge root at the test temp dir the way the
bridge's own tests do so the tests pass under any TMPDIR.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: satisfy the type and hardcoded-model gates
pyrefly on the pre-commit gate rejected five shapes the routing work
introduced: an inferred `dict[str, int | str]` hook literal that could
not take the route-turn entry, two `Awaitable` resolver results handed to
`asyncio.run_coroutine_threadsafe` (which takes coroutines only), and two
locals — `_parent_conv`, `_auto_harness` — read on paths where only a
narrower branch had assigned them. It also flagged the create path
rebinding `conv` from `get_conversation` without a `None` check, which
made every later attribute read an error; it now raises the same
`INTERNAL_ERROR` its sibling label writes do.
The router's static model tables moved to `omnigent/model_fallbacks.py`
as owned `StaticModelFallback` records — the repo's only sanctioned home
for a static model id, per the `no-hardcoded-models` lint. Ids that are
composed from the gateway's model-route prefix (GLM's `system.ai.`
spelling) are now spelled that way instead of restated.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: cover the Smart Routing UI in the Playwright suite
The web changes add user-visible routing surfaces with no e2e_ui coverage,
which the E2E UI Required gate flags. Two specs, following the suite's
established stub patterns:
- `start_session/test_smart_routing.py` — the landing picker's Smart
Routing row (create sends `harness_override: "auto"` +
`smart_routing_message`, and none of the placeholder wrapper's knobs),
Smart Routing as the gear modal's Model choice (create sends
`cost_control_mode_override: "on"`, no pinned model), and the negative
gate: a server with routing off offers neither.
- `chat/test_smart_routing_session.py` — a routed session's two audit
rows (create-time `session` chip + first-turn `turn` chip) render as ONE
chip with the Databricks mark, and the session gear modal's Model row
names the router's fully-qualified pick instead of rendering blank.
Both run against the suite's spawned server with `/v1/info`, `/v1/hosts`
and `/v1/agents` stubbed, so neither needs gateway credentials.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: match the gateway's trusted parents on DNS labels
The AI Gateway trust check compared the parsed hostname against
dot-prefixed domain suffixes with `str.endswith`. Correct as written (the
leading dot is what rejects `evilcloud.databricks.com`), but the safety
rests on a spelling convention in a constant, and a string-suffix test on
a domain literal is exactly the shape static analysis flags as incomplete
URL sanitization.
Compare whole DNS labels from the right instead, requiring at least one
label of the host's own in front of the parent domain. Same verdicts,
with the boundary now structural, and tests pinning both look-alike
classes: a trusted domain that only appears mid-host, and a label that
merely ends in one (`evilcloud.databricks.com`,
`ai-gateway.notazuredatabricks.net`).
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: stop the routing hook's codex floor from blocking every launch
Raising `_CODEX_MIN_VERSION` to 0.145.0 for the routing PreToolUse hook
made `harness_cli_installed("openai")` report `version-too-low` on
0.137–0.144, which makes `harness_is_configured("codex")` false, which
makes the host refuse EVERY codex launch — plain sessions included — with
a misleading "run omni setup". CI pins codex 0.139.0, so the e2e lane
failed on it too.
Restore 0.137.0 as the launch floor and enforce 0.145.0 only where the
spawn gate is actually registered: both codex hook writers now probe
`codex --version` and, on an older CLI, log one line and drop the routing
bridge dir so no hooks are generated at all. Routing no-ops instead of
blocking, and the user's hooks.json stays symlinked.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: confirm the /effort dialog instead of hanging on its title
`inject_slash_command(auto_confirm=True)` polled `capture-pane` for the
hardcoded "Switch model?" and sent Enter only on a match. The web UI's
effort change injects `/effort <level>`, whose confirmation dialog is not
titled that — so it never matched, the dialog stayed open, the change never
committed and the pane was wedged for the next injection. The no-dialog
case also spent the whole 4s poll budget where the previous code spent
0.3s.
Make the hint a per-command parameter and keep an unconditional confirm
Enter as the floor, which is what the code did before the poll was
introduced: on the no-dialog case it lands on an empty prompt and is a
no-op. The three `/model` sites pass the title they know and keep their
fast path; `/effort` passes none, settles briefly and confirms blind.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: keep the spawn-routing apparatus off plain claude sessions
claude-native passed `auto_harness=True` hardcoded and the SDK path started
the router for every claude session, so a plain claude session carried a
loopback HTTP server, its thread, a bearer token on disk, and a `Task`
PreToolUse hook — a subprocess cold start on native, in-process on the SDK —
on every spawn, with a 30-40s worst case when the endpoint is wedged. All of
it for a verdict the server would never route.
Gate both starts on the session's routing class, the same one the codex
paths already read. A plain claude session now gets no router, no hook and
no token file, matching plain codex; a routed session (pinned or auto —
claude routes spawns in both) keeps everything, and the per-spawn
server-side gate stays as defense in depth.
Accepted consequence: the class is stamped at create, so flipping the gear's
Subagent-routing toggle on for a plain-created claude session is inert until
the session is recreated. That matches the stamped-at-create design the codex
paths already follow.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: stop plain launches from displacing the model picker slot
`claude_config_with_launch_model_pinned` ran on every claude-native launch.
Whenever the launch model is an exact id no family alias points at — a user
picking an older generation of a family the workspace still serves — it
overwrote `ANTHROPIC_CUSTOM_MODEL_OPTION`, taking the workspace's own picker
row with it.
The slot exists so a routed session can return to the model routing picked
for it. Nothing re-picks the launch model on a plain session, so gate the pin
to routed launches and leave a plain launch's env untouched.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: restore main's spawn-env secret-leak canary
The trim commit deleted this file by name collision with the routing
spawn-audit canary; it is main's own guard for clean_agent_env and was
never part of this PR's machinery.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: keep the router rendezvous out of logs
The subagent- and turn-router startup logs printed the handle's url, and
the hook's rejection diagnostics echoed the url read out of the
advertisement. Both values travel with the bearer token that authorizes
the loopback endpoint, so a log line was enough to point a reader at the
secret's neighbourhood; static analysis flagged the four sites as
clear-text logging of sensitive data.
Drop the url from all four: the session id and the bridge directory (or
the advertisement's file name) identify the rendezvous well enough, and
the advertisement itself is on disk for anyone debugging it.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: confirm an effort dialog that renders after the blind Enter
A command whose dialog text we cannot recognise — ``/effort`` — settled
0.3s and then Entered blind. On a warm session the confirmation renders
about 1.9s in, so that Enter landed on an idle prompt and the dialog that
arrived afterwards stayed open: the person's next message was typed into
the modal and swallowed.
Keep the blind Enter as the fast path, then keep watching the pane for a
dialog until the confirm timeout and Enter again if one turns up. With no
dialog text to match on, the watch uses a structural signal — a framed
menu of at least two numbered choices with one selected — which also
recognises the ``/model`` picker and steps around a composer draft that
merely starts with ``2. ``. A dialog already showing at the settle skips
the watch, so the common cases still cost one capture.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: derive claude launch routing state through the shared class
Both claude-native launch-metadata builders hand-derived
``routing_enabled`` from ``cost_control_mode_override`` alone, while
``routing_class_from_snapshot`` deliberately ORs in the auto-harness
signal. A sub-agent child of a routed parent is created with
``harness_override="auto"`` and the auto-harness label but no
cost-control stamp, so it launched ``routing_enabled=False`` with
``auto_harness=True``: no pinned arms, no launch-model pin, no turn
router and no subagent router — yet still carrying the routed-spawn
system-prompt note and the four pre-approved ``sys_*`` tools. Claude was
told to hand its spawns to a hook nothing answered.
Route both builders through ``routing_class_from_snapshot`` so the class
is derived in one place, and require the spawn router to have actually
started before the note and pre-approvals go onto the argv.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: stop offering subagent routing where it cannot work
The create path stamped ``subagent_routing_override="on"`` on every
session that started on Smart Routing, and the gear offered the
Subagent-routing select to every native Claude/Codex session. On a
session pinned to codex neither is real: spawn routing there needs the
generated ``hooks.json`` and the routed-spawn tool pre-approvals that
only an auto-harness launch installs, so the switch read "on" with
nothing consuming it. The same went for a plain native session of either
family, whose apparatus is fixed at create.
Leave the stamp off for a pinned codex create, and hide the row wherever
the session's class has no spawn-routing machinery — a claude-family
routed session and any auto-harness session keep both. Non-native
SDK/bundle sessions are untouched: their children go through the
session-create path, which re-reads the switch per spawn.
Subagent routing is now launch-time-fixed for codex.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: make the model switch land once, or say why it did not
Three faults left over from reverting the interactive ``/model`` picker.
The web/API model-change handler typed the resolved catalog id straight
into ``/model``, which takes only the pane's own picker vocabulary. An id
outside it left the pane on its old model while the handler reported
success. Translate through ``claude_model_command_arg`` like the routed
turn path and the executor already do, and fail with a clear 503 when the
picker has no spelling for the model.
A routed first message switched twice. The turn router blocks the prompt,
types the switch and replays the prompt with the same override, but the
executor seeded its baseline from ``launch_model`` — written once at
bridge prepare — so the replay compared against the pre-switch model and
typed a second, redundant ``/model``. Seed from the live statusLine model
instead, and compare normalized.
A dropped forward was invisible. The PATCH persisted ``model_override``
and discarded the forward's result, so on a native pane — where the
injection is the only thing that moves the model — the row and picker
claimed a model the terminal was never on. Publish a visible notice and
log the reason. The forward budget also went up: the ``/model`` and
``/effort`` injectors can legitimately spend ~5s waiting on the pane and
its confirm dialog, which the old 5s budget would have reported as a
failure.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: clear the routing punch list's small residuals
- The install and credential routes recorded ``gateway_inference`` straight
off the host's RPC reply, so a host answering with anything other than a
string→bool object 500'd them inside ``dict(...)``. Decode through the
same tolerant reader the tunnel path uses, where a non-mapping is
"unknown".
- Reworded the routing docstrings that cited design documents no longer in
the repo; the behaviour they described is stated inline, and the e2e
suite in tests/e2e/routing/ is the executable reference.
- ``routing_enabled(caps=)`` read the routing backends directly, which
misses the managed arm where only a policy-LLM factory is registered and
the routing client arrives later. It goes through ``routing_available``
now, the same gate the rest of the server uses.
- The codex model-catalog cache was keyed on binary path plus codex home,
so an in-place upgrade (same path, new bytes) served the previous
codex's catalog for the life of the host process. The binary's mtime and
size are part of the key now.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: match the gear's comments to the narrowed subagent gate
The two comments still described the old "every native Claude/Codex
session" rule. Say which classes carry the apparatus and which the row is
hidden for.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: pin that the late-dialog Enter only answers our own dialog
The extra Enter is scoped to a dialog that appeared after the settle, so a
menu already open when the command was injected — a live permission
prompt, say — still takes only the single blind Enter this seam always
sent. That property is what makes widening the confirm window safe, so it
gets a test and a note rather than living in the reviewer's head.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: answer the effort dialog by name, not by shape
The effort confirm watch Entered on any dialog that turned up during its
4s poll, so a ``/model`` picker the person opened by hand — or a tool
permission prompt that rendered mid-turn — took the Enter too: the first
silently rewrites their global default model, the second silently
approves the tool.
Claude Code titles both cache-invalidation confirmations from one
component, so ``/effort`` has a title to poll for just like ``/model``:
"Change effort level?". Pass it as the effort call's ``confirm_hint`` and
drop the shape-matching watch — ``auto_confirm`` now requires a hint. The
timeout Enter stays, so a title that drifts in a future release does not
wedge the pane, but is withheld when the pane shows a picker or a
permission prompt. The readiness gate learns the effort title too, so an
open effort dialog no longer reads as "an injection may land".
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: suppress the codex subagent stamp only where it is inert
The create-time subagent_routing_override stamp was skipped for anything
whose harness family is "gpt". That also caught an SDK/bundle agent whose
brain is codex or openai-agents — and those spawn their children through
the session-create path, which re-reads the switch per spawn, so the
stamp is exactly what gives them default child routing. Skipping it took
that away, and disagreed with the gear, which offers the row on every
non-native session.
Suppress only where the switch really has nothing behind it: a NATIVE
codex terminal, whose spawn routing comes from the hooks.json and
tool pre-approvals an auto-harness launch installs. The server and the
gear now agree class by class: native pinned-codex hides the row and
writes no stamp; a codex-brained bundle keeps both.
The old fixture had no spec harness, so it never reached the family
check; the new case pins a codex-brained bundle on both sides.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: clear the routing punch list's last three residuals
- The "terminal was not switched" banner fired on stopped and detached
native sessions too, where nothing was running to diverge from: the
relaunch reads model_override off the row. Surface it only when a runner
actually answered and refused, which is the reachability the /health
liveness field reports.
- Add the credential route the tolerance test the install route got: a
host reply whose gateway_inference is a list must read as "unknown", not
500 with the credential already written. The install test never proved
that — its garbled value was dropped by the fixture before it reached
the frame — so both now inject at the proxy's return, past the decoder
that would otherwise normalise it away.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* test: drive the gateway-flip repush through the readiness loop
Upstream moved readiness refresh into its own task; the flip test now
exercises that loop directly instead of the removed tunnel helper.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: log nothing that addresses the router rendezvous
The redaction kept the session id and bridge path, which still name the
loopback endpoint whose advertisement carries the bearer token. The
start-up lines and the marker-failure notice now carry no values at all.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: make routing fail open in seconds, not in half a minute
Routing was already advisory everywhere it mattered, but the budgets meant
a wedged router still stalled the work it was supposed to get out of the
way of: a subagent spawn sat behind a 30s request inside a 40s hook kill,
and a first typed prompt sat behind 25s inside 45s. A fail-open that takes
that long is blocking in practice — the user cannot tell it apart from a
hang, and the turn they were promised runs no sooner for the wait.
Retune every routing ladder around one number: the routing call itself gets
5s, sized from the observed round trip (healthy routes:select answers in
~1.4-3s; the slowest sample on record was a gateway 500, not a verdict).
Each hop above it takes one more second, out to the harness-registered kill
at 15s (spawn gate 12s), which is now the only budget above single digits.
One attempt, no retry: a second try on an interactive path only doubles the
stall.
Two budgets on these paths were unbounded rather than merely long. The
built-in judge inherited the server `llm:` block's 300s request timeout,
multiplied by every configured fallback model, so picking the OSS router as
the source turned a fail-open into a multi-minute hang; it now shares the
external router's 5s. And the stale native model-options refresh, awaited
only to sharpen a routing candidate list, retries a booting runner for
~30s; routing now waits 3s for it and lets the single-flight finish filling
the cache on its own.
The CLI's preflight reads move off the create's 60s read budget too. They
answer in milliseconds and every failure already degrades to "unknown",
which does not gate, so there was nothing to win by waiting. The create's
own budget is left alone: that one is a session create, not a routing call.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: stop a routing outage from 500ing the turn it was routing
`route_turn` was the one routing seam that let its failure out. Its two
callers on the message path did not guard it, so a client that raised
instead of declining — a gateway 500 surfacing as HTTPStatusError, a read
timeout, a garbled body, a 401 — propagated to `POST /v1/sessions/{id}/
events` as a 500. By then the user's message had already been persisted, so
the turn was not merely unrouted: it was persisted and abandoned. Its
sibling `route_session_harness` has always returned an `error` string for
exactly this, which is what made the asymmetry easy to miss.
Add `route_turn_or_decline` as the turn path's fail-open boundary, in the
same `(model, verdict, error)` shape, and take the visible half of failing
open with it: the declined `routing_decision` card the auto-harness path
already emitted ("unavailable", applied=False) now covers the turn and the
native-pane paths too, so a session does not quietly ignore the toggle the
user turned on.
A failure deliberately does NOT stamp the routing-decision label. That label
is the route-once gate, so claiming it would turn one outage into the reason
the session never routes again — the failure is a card, not a decision.
Everything else audited on the routing paths was already fail-open and stays
untouched: the CLI's routed create and its auto-harness fallback, the
create-time server paths, the spawn-gate relay, both first-message hooks,
the loopback relays, both clients, and the model-switch application step.
The precondition gates that decline before anything starts are also left
alone — those are config rejections the owner asked for, not call failures.
Regression coverage for both properties (work proceeds, budget respected)
across gateway 500 / timeout / malformed body / 401 / unreachable relay, at
every call site: the SDK turn path, the native pane path, the spawn relay,
the first-message relay, both create paths, both hook scripts, both clients,
and the CLI's non-routing-400 fallback notice. Timing assertions are against
the ladder constants, never a wall clock.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: give a child spawn's failed route the same visible decline
`route_session_harness` returns its reason as an `error` string, and the
child-spawn branch of the message path unpacked it into `_route_err` and
then never read it. So the last routing path that could not route left no
card at all: the spawn ran on whatever the orchestrator had asked for, which
is right, but from the transcript "the router was down" and "the router had
no opinion" were the same thing.
Emit the same "unavailable" card the auto-harness and turn paths emit. Set
last, after the branch's own pin and publish, so nothing upstream can pin or
announce the placeholder — and leave the route-once label unclaimed, because
a child routes per spawn and `_child_routed_before` reads that label, so
stamping it on a failure would stop the child from ever being routed again.
The flag is renamed `_route_failed` now that both branches set it.
Also covers the bounded catalog wait: a stale-catalog refetch that never
finishes serves the stale vocabulary within `_ROUTING_CATALOG_WAIT_S` and
leaves the single-flight running to fill the cache for the next turn.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: let a pinned Smart Routing codex session actually spawn
Suppressing the create-time subagent-routing stamp for a native pinned-codex
session was justified on the theory that the switch would be inert there. It
was worse than inert: the pinned class was also withheld the spawn-routing
advertisement, and on codex that advertisement is what turns on the generated
``hooks.json`` ``spawn_agent`` gate AND the four routed-spawn tool
pre-approvals. A pinned Smart Routing codex session therefore had no spawn gate
and no pre-approved cross-session spawn tools, so its spawns did not merely go
unrouted — they stalled on an approval prompt nobody was watching.
Stamp every routed create again, and start the endpoint for a routed
codex-native launch whether or not the harness was auto-picked, which brings
the gate and the approvals with it. The codex SDK arm keeps the auto-harness
requirement: its spawns go through the session-create path, which already
routes off the stamped switch, so an in-harness gate would only add a round
trip. Plain sessions still get none of it.
What separates pinned from auto-harness is not whether spawns route but where
they may land: ``cross_harness`` stays ``auto_harness_session``, so a pinned
codex spawn is offered codex arms only and a claude pick is denied. The web
predicate now shows the gear's Subagent-routing row for exactly the classes the
server stamps.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: collapse a repeated routing verdict into one chip again
A Smart Routing create records its pick as a session-scope chip and the first
turn records the identical pick as a turn-scope chip; only the turn chip should
render. The pairing test asked whether the two decisions were ADJACENT, using
the same neighbour walk that decides where a chip sits relative to the message
it routes. That walk steps over exactly the blocks allowed between a chip and
its message, so anything else a booting session emitted between the two
decisions — narration, an earlier message, a whole finished response — read as
"unrelated" and both chips rendered.
Pair them by decision order instead: the next routing decision anywhere later,
across intervening blocks and turn-group boundaries. A turn chip that CHANGED
the pick, a declined create-time route followed by an applied one, and a spawn's
deny-then-honor pair all still render as two — the first two because the
verdicts differ, the last because a subagent-scope decision is never the
supersessor.
The incremental path had its own hole: the create chip is finalized into the
cached prefix frames before the turn chip exists, and the drop was computed only
from the walk's resume point, so a chip already in the prefix could never be
removed. The verdict set is now resolved over the whole transcript and
remembered on the cache, and a disagreement over the prefix forces the single
rebuild that removes the stale chip.
For the record, the resource_event in the reported transcript is not the
mechanism: an unknown item type yields no block from itemsToBlocks and
session_resource_created adds none on the live path, so it never separated the
two. The wire rows are kept as a funnel regression test regardless.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: keep a pinned session's spawns in its own harness family
A pinned Smart Routing codex session spawned a claude child and the router
pinned it to claude-sonnet-5: the in-harness spawn gate holds the in-family
line (candidate_models(cross_harness=False)) but the child-session route on
the native-terminal dispatch path had no such rule. It routed whatever
family the child's own pane ran, so an orchestrator that named another
family's wrapper agent got a cross-family spawn blessed by routing —
against the standing ruling that only an auto-harness session may cross.
The native child path now asks the same predicate the spawn gate does
(auto_harness_session(conv, parent)) and, for a pinned parent whose child
runs another family's CLI, routes nothing: no pin, no in-band /model, and a
declined chip naming the rule. The spawn itself still runs, on its CLI's
own model.
Also resolve a native pane's family from the terminal it is actually
running rather than an unresolved "auto" sentinel. The sentinel carries no
family, so a forced-auto child was offered every model its gateway serves
and could be pinned to one its running CLI cannot speak.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: render one routing chip per spawn, not two
One spawn produces two decisions — the in-harness gate sizes the task, then
the child session it created routes its own first message — and the
transcript showed both: one chip labelled "Session" (the gate row carries no
agent name) and one naming the spawned agent, with the same rationale. To
the owner that is one decision about one spawn.
The pair now collapses onto the child-session row, which is the informative
one: it names the spawned agent and the arm that actually ran, keeping the
gate's own pick visible as the router's raw verdict when a tier
substitution moved it (opus-4-8 -> opus-5). The two rows share no spawn id
— different decision ids, no agent on the gate row, minutes apart — so the
pairing key is the verdict: the same non-empty rationale AND the child
running the arm the gate picked. A deny-then-honor pair, two independent
spawns, and two genuinely different verdicts all still render as two chips.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix: name the cause on a routing decline that had none
A live decline read "Routing unavailable (router request failed: )" — a
dangling colon with the reason missing. httpx's timeouts stringify to the
empty string, so the exception the fail-open budget produces most often was
also the one that said nothing. Every routing failure string now falls back
to the exception class ("router request failed: ReadTimeout"), which is what
a 5s budget firing looks like.
The subagent gate had a second way to lose the cause: a client that raises
before it can record its own last_error left the chip saying only "router
returned no verdict", with the real failure in the server log alone. It now
carries the raised cause when the client reported none.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* docs: document the PR review process for contributors
The issue requirement, the review-state labels, and the 7-day close were all
built and shipped without ever being written down, so a contributor's first
encounter with any of them was a bot comment.
CONTRIBUTING now covers: that every PR needs a linked issue and how to link one,
what the two exceptions are, what `waiting-on-author` and `waiting-for-review`
mean and that automation manages both, and that a PR left waiting on the author
for 7 days is closed and reopenable with /reopen.
It states the 5 August 2026 cutover explicitly: maintainers follow this process
for new PRs, PRs opened earlier are being worked through separately and may not
carry the labels yet, and the issue rule does not apply retroactively. Without
that, a contributor reading the doc would expect labels on a 3-week-old PR and
conclude it had been dropped.
The bot's nudge is rewritten to match: it opens by thanking the author, says the
requirement applies to every PR rather than only naming what is missing, promotes
"open an issue first" to its own line, and closes the exemption loophole by
spelling out that a bug fix or feature needs an issue even when it also touches
docs or tests. A test pins that wording.
Also drops em dashes from the contributor-facing text in the workflows added
today, per house style.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): accept "Part of #N" as a tracked issue
GitHub only creates a link for the closing keywords, so a PR saying "Part of
#123" reads as unlinked to closingIssuesReferences and would have been nudged.
That punished the honest case: a PR that advances an issue without finishing it
had to either claim `Closes` (which closes an unfinished issue on merge) or take
the comment.
Non-closing references now satisfy the rule: Part of, Related to, Towards, Refs,
References, See. Closing keywords and sidebar links still work and are still
preferred, since only those close the issue for you.
Two limits keep it from becoming a free pass. A bare `#123` does not count, being
a cross-reference rather than a claim about this PR. And the reference must
resolve to an issue: "Refs #4147" pointing at another PR is not a tracking
record, which is the shape three PRs in the current backlog have.
Found because #4095 says `Refs #3644`, a real issue, and would have been flagged.
It escaped only because its author is a maintainer.
Verified against production: #4095 now satisfies the rule, and all seven currently
flagged PRs still flag.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Steering a claude-native turn mid-tool-use makes Claude write its own
"[Request interrupted by user for tool use]" record into the transcript
BEFORE the steering message. The forwarder mirrors both back as user
items, and `_persist_external_conversation_item` treated every mirrored
user message as the round-trip of a queued web message: it FIFO-drained
a pending-input entry and folded that entry's uploaded image/file blocks
into the item.
The interrupt record has no pending entry of its own, so draining for it
shifted the queue by a slot — the marker absorbed the queued message's
uploads and the real message persisted with none. In the web UI that
rendered as the raw marker text sitting beside the screenshots (the
system-marker gate bails out when a bubble has attachments) followed by
a blank bubble (the real message's absolute-path "[Attached: …]" markers
are stripped, and its file blocks were gone). It persisted that way, so
it survived reload.
Exempt the vendor CLI's own interrupt record from the drain. Runtime
"[System: …]" notices are deliberately NOT exempt: they are posted
through POST /events and record a pending entry of their own, so their
mirror-back must keep draining. The predicate matches on the first line
only, exactly as parseSystemMessage does web-side — a record the web
hides as a marker but the server drains for would reintroduce the bug.
chatStore's session.input.consumed handler had the same flaw on the live
path, so its FIFO-head fallback now holds back system markers too. A
"[System: …]" notice still lands on the drop-by-id branch via
clearedPendingId, so it is unaffected.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Unarchiving from Settings -> Archived sessions left the user on the
settings page with no sign of where the restored session went. The row
simply vanished from the archived list, so bringing a session back took
a second step: find it again in the sidebar.
Navigate to /c/{id} once the unarchive PATCH lands, so the restored
session opens where the user expects it.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Clearing the label and closing on it were automated; setting it was not. A
maintainer who left feedback without remembering the label got none of the
machinery -- no handoff back on reply, no 7-day clock.
Any non-approving engagement from someone with write access now applies it: a
review, a review-thread comment, or a PR comment. "Request changes" was too narrow,
since most feedback here arrives as a plain comment.
Deliberately excluded:
- approvals -- nothing is owed by the author
- slash commands (`/review`, `/reopen`, `/merge`) -- they drive automation rather
than ask for anything, so they must not flip a PR back to the author. Matched
only at the start of the body, so prose mentioning /review still counts.
- bots, and the author themselves even when they are a maintainer
Write access is read from the collaborator permission API, not the event's
`author_association`, which reports CONTRIBUTOR for a maintainer whose org
membership is private. It fails closed, so a stranger's comment never moves state.
Author activity still wins when both could apply, and applying the label clears
`waiting-for-review`, keeping the two mutually exclusive.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): hold the transcript still while the composer grows
Adding a newline with Shift+Enter shunted the whole transcript down a
line, and the scrollbar and turn rail jittered along with it.
Two causes. The auto-grow hook reads its content height by collapsing the
textarea to `height: auto` — a one-row box. For the one layout that lasts,
the composer is short and the transcript's scroll viewport is taller, so
the browser clamps its scrollTop against the smaller maximum; the clamp
survives the composer springing back. Pinning the wrapper's height keeps
that collapse inside the composer.
The composer was also a plain flex sibling, so every extra row genuinely
stole height from the transcript's viewport. Messages could be held still
through that, but the native scrollbar (drawn from clientHeight/
scrollHeight) and the turn rail (centered on the same box) could not. The
hook now reports how far past its resting height the textarea has grown,
and the form offsets that with a negative top margin — its margin box
stays one row tall, the extra rows float over the transcript, and the
three overlays pinned to the transcript's bottom edge track the growth so
they keep meeting the card.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): publish zero growth when the composer has no layout
Addresses review notes on the auto-grow hook: the scrollHeight === 0 path
returned without reporting, so a caller offsetting its layout by the last
value held that offset across a route swap until the next measure. Also
corrects the resting-height comment, which named a min-height the landing
composer no longer sets.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e): poll for settled layout instead of fixed sleeps
Addresses a review note: the fixed wait_for_timeout guesses were the
likeliest source of future flake under CI load. Reading the probe once two
consecutive reads agree can't return mid-settle, and costs nothing once the
layout is already quiet — the test also drops from ~4.6s to ~1.6s.
Re-confirmed non-vacuous by ablation.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
The closer told authors to "reopen this PR or open a new one", but reopening needs
Triage+ on the base repo, which a fork contributor does not have -- so the advice
was unactionable for exactly the people receiving it. One author hit this last
week and had to re-raise their work as a fresh PR.
`/reopen` now exists, so point at it, and say what to do when the source branch is
already gone (the case where nothing can bring the PR back).
Also borrow Spark's framing that the close is not a judgement on the PR's merit.
An explained, reversible close is what keeps auto-close socially acceptable;
research on stale bots finds they shrink contributor counts along with backlogs.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
## Related issue
N/A
## Summary
`generate_formula.py` runs `uv pip compile --no-config`, which discards the repo's
`exclude-newer = "P7D"` along with the index and uv-version config. The cooldown
therefore never applied to the Homebrew formula: every one of the ~100 resource
pins in the artifact `brew install` users receive could be a distribution
published minutes earlier, even though the same dependency graph in `uv.lock` has
to wait the window out. A supply-chain control we apply to our own resolution was
absent from the one thing we ship to end users.
- Re-apply the window explicitly with `--exclude-newer`, keeping `--no-config` so
the index and `required-version` stay out of the picture.
- The cooldown cannot simply be left enabled: at release time `omnigent` and its
two lockstep SDKs are minutes old, and uv filters out the very version being
packaged (`no version of omnigent==X.Y.Z`). Those three are exempted with
`--exclude-newer-package`, which is what uv's own error message recommends.
- The span is read from `uv.toml` rather than hardcoded, so the formula's cooldown
cannot silently drift from the lockfile's. If it cannot be read, it falls back
to 7 days with a warning — never silently to "no cooldown".
- `--cooldown-days` overrides it for local experiments.
Pre-existing since #2654; every formula generated since has had it, including the
0.8.1 one that just shipped.
## Test Plan
Three runs against `omnigent==0.8.1`, all through a PyPI mirror:
- **No-op check** — cooldown 7 vs 0 at the same moment: **0 of 100 pins differ**,
so this does not churn today's output. (An earlier comparison suggested 3 pins
moved; that was mirror lag between two days, not the cooldown — the controlled
run is the valid one.)
- **Enforcement** — cooldown 7 vs 60: **45 pins held back**, e.g. `fastapi`
0.141.1 -> 0.136.3, `mcp` 1.29.0 -> 1.27.2, `grpcio` 1.83.0 -> 1.81.0. So the
flag demonstrably filters.
- **Exemption** — at a 60-day cooldown, `omnigent==0.8.1` (published 2 days ago)
still resolves and is still pinned as the stable url, which is only possible if
`--exclude-newer-package` is working. Without the exemption, resolution fails
outright; verified separately by running `uv pip compile` from the repo root
with the cooldown active:
`No solution found ... omnigent was filtered by exclude-newer`.
Also `ruff check`, `ruff format`, and the module imports with
`cooldown_days()` returning 7 from the repo's `uv.toml`.
## Demo
N/A — release tooling, no user-visible UI.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
The generator has no test suite here, and the property that matters — "resource
pins respect the cooldown" — depends on live PyPI upload times, so it cannot be
asserted hermetically. Verified by the three controlled runs above: a no-op
against today's output, 45 pins moving under an exaggerated window to prove
enforcement, and the lockstep exemption proven by 0.8.1 resolving despite being
2 days old.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
The notice failed with "Resource not accessible by integration" on every close.
Posting a comment on a pull request goes through /issues/{n}/comments, but GitHub
gates that on `pull-requests` when the target is a PR, so `issues: write` alone is
not enough -- every other comment-posting workflow here declares both.
Found by closing a throwaway PR after the merge: the run failed and no notice was
posted. reopen-pr.yml already declares both, so /reopen itself was unaffected.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): let PR authors reopen a bot-closed PR with /reopen
Reopening a PR requires Triage+ on the base repo, so a fork contributor
(Read only) cannot undo an automated close -- their only option is filing a
fresh PR. The bot has the permission, so it now does it on their behalf.
Guarded so it can only undo automation, never a maintainer's decision: the
commenter must be the PR author, the last close must have been the bot, and a
merged or already-open PR is ignored. A deleted head branch (which makes reopen
impossible for anyone) gets an explanation instead of a silent failure.
The duplicate-PR closer now advertises the command in its close comment, since
an escape hatch nobody knows about is not one.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): comment reopen instructions on every unmerged PR close
An escape hatch only helps if it is visible at the moment it is needed. Document
/reopen in CONTRIBUTING.md, and comment on close so an author looking at their
closed PR sees how to get it back without hunting for docs.
The notice is tailored to who closed it, because the answer differs: an author
who closed their own PR is told to use /reopen (they cannot press Reopen either,
being Read-only), while a maintainer close points them at the maintainer, since
/reopen deliberately will not override that. Bot closers post their own notice
and GitHub suppresses the closed event for GITHUB_TOKEN closes anyway, so this
covers human closes. A hidden marker keeps close/reopen/close from re-notifying.
Also widen /reopen to author self-closes, which have the same permission wall as
bot closes.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): make the reopen notice work on fork PRs
The notice workflow ran on `pull_request`, whose token is read-only for fork PRs
no matter what `permissions:` asks for, so commenting would have 403'd on exactly
the community PRs the feature exists to help -- and the workflow comment claimed
the opposite. Run it on `pull_request_target`, which gets a grantable token in
the base-repo context; the job already checks out only the default branch's
.github and runs no PR code, so nothing about the trust boundary changes.
Treat any `[bot]` close as automated instead of allowlisting github-actions[bot].
The notice already matched by suffix, so a close from a GitHub App would have
advertised /reopen and then been refused as a maintainer close.
`/reopen` now has to be a command rather than a mention: the workflow `if:`
prefilters on the substring, so "see /reopened elsewhere" reached the script.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): hand PRs back to the reviewer with waiting-for-review
`waiting-on-author` can only say a PR is stalled. It cannot say the opposite, so
when an author replies the PR silently leaves the author's queue without entering
anyone else's -- and GitHub clears the review request the moment a review is
submitted, so the reply is invisible in the reviewer's queue too.
Add `waiting-for-review` as the other half of the cycle. Every path that clears
`waiting-on-author` now also applies it and re-requests the PR's owners, taking
them from `assignees` (the durable record) plus any surviving requested reviewers,
never the author. A failed re-request warns instead of failing the handoff, since
a reviewer can lose access.
The two labels are mutually exclusive: labeling a PR `waiting-on-author` removes
`waiting-for-review`, so a PR never advertises both states. That needs the
`labeled` trigger, which the workflow now subscribes to.
This is the label maintainers filter on to find PRs that are actually ready for
them, rather than reading the whole open list.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): re-request reviewers one at a time
GitHub rejects the whole reviewer batch when any single login is invalid, so a
maintainer who has since lost repo access would have silently taken the other
valid owners down with them -- the opposite of the resilience the batch call was
meant to provide. Request per reviewer and report which one was dropped.
Also warn when the handoff labels a PR waiting-for-review with nobody queued.
Auto-assign normally populates assignees, so an empty queue means something
upstream skipped the PR, and the label would otherwise advertise a state no
reviewer is actually in.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): satisfy ruff in the reviewer-request test
The fake request() override has to keep the base signature, so `method` looked
unused (ARG002). Assert on it instead of silencing the rule -- the test only ever
expects a POST.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): flag PRs that link no issue (dry run)
Linking a PR to an issue is what gives it a priority in the review queue, but
329 of 480 open PRs link nothing, so most of the queue arrives unsorted.
Add an hourly issue-link check to the PR-hygiene sweep. It flags a PR with one
comment plus `missing-issue-link` and never closes anything: the label is the
signal a future merge gate or closer can read, following Prow's split where
plugins only label and merge blocking lives elsewhere.
It ships as a dry run. ENFORCE defaults to "false", which resolves every verdict
into the step summary while changing nothing, so the full list can be reviewed
before a single contributor is commented on. LIMIT caps flags per run.
Exemptions: bots (our CI bots author as CONTRIBUTOR, so an author_association
check would miss them), drafts, trivial changes (<= 9 lines, the size/XS
threshold), reverts, the `skip-issue-check` label, a `no-issue` line in the body
(a first-time contributor can type a line but cannot apply a label), and an
affirmatively checked Refactor / Docs / Test box. That last one requires a
declaration: exempting on the *absence* of a checked box would have made
deleting the template the cheapest way to skip the rule, which measured at 105
PRs versus 23 genuine chore declarations.
Link status is resolved per PR via closingIssuesReferences rather than a body
regex, so sidebar links, cross-repo refs, and full issue URLs all count -- forms
a keyword regex misses, and two of them appear in our own backlog. A failed
lookup fails closed and leaves the PR alone.
Rename the workflow to PR Hygiene now that it carries two checks, and rewrite
the template's "N/A" guidance to name the two escape hatches the bot honors.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): exempt maintainer PRs from the issue-link check
Nudging ourselves adds noise without changing our own behaviour, and maintainer
PRs were 79 of the 228 the dry run flagged.
Exempt on either signal, the same union demo-check.js uses: authorAssociation of
MEMBER/OWNER/COLLABORATOR, or a login in .github/MAINTAINER. Both are needed --
a maintainer whose org membership is private reads as CONTRIBUTOR, and one
maintainer holds write access without being listed in the file. The file is read
from the API rather than the checked-out tree so a PR cannot self-grant by
editing it.
Dry run after the change: 149 flagged (was 228), 210 exempt of which 112 are
maintainers.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* Update pull request template for issue association
Clarified instructions regarding issue association for certain types of changes.
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(ci): address Polly review on the issue-link check
The dry run existed so the whole verdict list could be read before any
contributor was commented on, but LIMIT was applied before the enforce gate, so
a dry run capped its own list at 25 and could never show it. Move the cap under
the enforce path.
Pin the rule to an effective date. The 24-hour window already kept the sweep off
the backlog, but that was a property of the window rather than of the rule; a
wider window or a manual run would have reached back. Nothing opened before the
effective date is considered now, whatever the window says.
Ticking Test / CI beside Bug fix was a free opt-out, since the exemption fired on
the presence of any chore-ish box. A tracked type now wins over an exempt one.
Also: LIMIT=0 meant unlimited rather than "flag nothing", and the trivial-lines
comment claimed parity with size/XS, which excludes lockfiles while this counts
raw additions plus deletions.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* refactor(ci): drop the missing-issue-link label
The nudge is a one-shot message, so a label alongside it only adds noise to the
queue maintainers filter on. Dedupe on a hidden marker in the bot's own comment
instead -- the same approach reopen-notice.js uses -- and drop the label creation
entirely.
The comment lookup happens only for PRs that reach the flag decision, so a dry
run still costs nothing extra per PR.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* refactor(ci): remove the no-issue self-service opt-out
A rule that anyone can opt out of by typing one line is not a rule. `no-issue`
let exactly the PRs this check targets skip it, so drop the regex, the bot
comment's mention of it, and the exemption.
What remains is a declared Refactor / chore / Docs / Test / CI type, which is a
statement about the change rather than a bypass, and the `skip-issue-check` label
for maintainers -- the only unconditional opt-out, and it needs write access.
The test now asserts `no-issue` in the body does nothing, so the hatch cannot
quietly return.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): make a malformed LIMIT fail toward flagging nothing
`Number("abc")` was falling through to Infinity, so a typo in the workflow env
would have removed the cap that bounds how many contributors one enforcing run
can comment on. Warn and flag nothing instead.
Also read .github/MAINTAINER from the event's default branch rather than a
hardcoded "main", matching the sibling checks, and fix the sweep's header comment,
which still claimed both checks dedupe on a label.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(sandbox): supervise the in-sandbox host so a crash can't strand the box
A sandbox container outlives the host process: PID 1 is `sleep infinity` or
the provider's own init, never `omnigent host`. So when the host dies the
container stays healthy and still billing, with nothing running in it.
Nothing notices until the next message, and the only recovery is
`relaunch_managed_host` re-provisioning a fresh sandbox — which discards the
workspace: the clone, the installed dependencies, the harness state.
Wrap every exec-model host launch in a restart loop at the one seam all
providers funnel through (`run_background`), so a crashed host restarts in
place and the workspace survives. No image changes, no init system, no new
privileges — replacing PID 1 across seven provider images would mean booting
systemd with cgroup mounts, which the Kubernetes Pod's "restricted" security
posture forbids outright.
To make restarting safe, give a permanent startup failure its own exit code
instead of sharing 1 with generic crashes: without it, a revoked or expired
launch token inside a remote sandbox becomes an invisible hot restart loop
with nobody watching a terminal. The supervisor stands down on that code, on
a clean exit, and on SIGTERM; anything else is a crash, retried with a
doubling delay capped at 30s.
OpenShell keeps its held exec stream — it reaps an exec's processes when the
RPC returns, so `setsid nohup` genuinely cannot work there — but gains the
same supervisor inside that stream. Kubernetes is untouched: it is
entrypoint-as-host with a deliberate `restartPolicy: Never`, recovering by
provisioning a replacement Pod rather than restarting in place.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(sandbox): make the supervisor's stop contract and backoff cap explicit
Review follow-ups on the in-sandbox host supervisor.
A signal-kill of the host alone (SIGKILL -> 137) stays classified as a crash on
purpose: that is what an OOM kill looks like, and restarting is the wanted
response. The consequence is that a path meaning to STOP the host must signal
the supervisor too, or the loop faithfully restarts it. Both in-sandbox stop
paths already do — `foreground_kill_command` signals the pidfile's recorded pid
(the supervisor, which the host `exec`s under), and islo's preserved-daemon stop
matches "omnigent host" against full argv, which the supervisor's own `sh -c`
argv contains. Documented so a future narrowing of either match doesn't silently
turn a stop into a restart loop.
The loop deliberately has no attempt ceiling — giving up would restore the
stranded-empty-box failure it exists to prevent — so add an attempt counter to
the restart log, making a persistently crashing host observable instead of an
indistinguishable repeat.
Cover the backoff clamp with a test asserting the full delay sequence
(1, 2, 4, 8, 16, 30, 30, 30), and point the `_harness_cli_version_string`
timeout example at READINESS_CLI_PROBE_TIMEOUT_S instead of a stale literal
that disagreed with it.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(host): keep the tunnel receive loop responsive during readiness refresh
The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.
Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.
Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test: accept the readiness probe timeout kwarg in harness CLI stubs
Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(host): cover off-loop readiness refresh and bounded CLI probe
Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(db): rename projects.owner_user_id to user_id
Migration b3c1a2d4e5f6 unified the session-owner identity columns on the
schema-wide `user_id` convention, converting `hosts.owner` and
`scheduled_tasks.owner_user_id`. The `projects` table shipped five days
earlier (b1c2d3e4f5a6) and was missed, leaving it the last column still
diverging from `session_permissions.user_id`, `account_tokens.user_id`,
`device_grants.user_id`, `hosts.user_id`, and `scheduled_tasks.user_id`.
Renames the column, the entity field, and the store/route keyword argument.
`ix_projects_owner_user_id` becomes `ix_projects_user_id`, matching the
`ix_scheduled_tasks_user_id` precedent. `ix_projects_name` keeps its name —
the store's `_is_name_conflict` matches on that literal — but now covers
`user_id` and stays UNIQUE.
Type is unchanged (VARCHAR(128), nullable) and the rename is not
wire-visible: `owner_user_id` was never part of the ProjectObject response.
Co-authored-by: Isaac
Signed-off-by: aravind-segu <aravind.segu@databricks.com>
* refactor(db): drop the projects name UNIQUE index; compress config
Addresses two schema-review comments on the managed-schema mirror of this
table (databricks-eng/universe#2369565). Both are OSS model changes that the
managed USM schema then follows, so they land here first.
1. Drop `ix_projects_name` (UNIQUE over workspace_id, owner, name).
Folded into the same migration as the user_id rename, which already dropped
and recreated this index. It backed only the store's two `_name_taken`
probes, which now stand alone as the sole per-owner uniqueness check:
- It never held for single-user mode, where the owner column is NULL and SQL
treats NULLs as distinct, so that deployment has always allowed duplicates.
- `name` is mutable (`update` renames it), so a unique key over it was
maintained on every rename.
- The `?project=<name>` member join tolerates duplicate names by
construction: it unions first-class members with `omni_project`
label-projects matched on the same string, so name-collision merging is
already its defined behaviour.
The cost is that two concurrent creates or renames to the same name can both
land. `ix_projects_user_id` still covers both probes via its
(workspace_id, user_id) prefix, then filters `name` over the owner's handful
of rows, so neither query is left unindexed. `_is_name_conflict` and both
now-unreachable `IntegrityError` handlers are removed rather than left as
dead protection. The downgrade recreates the index, which will fail if
duplicates accumulated while it was absent — deliberately, so the conflict
surfaces instead of a row being discarded.
2. Store `config` as a compressed BLOB/BYTEA (new migration e6f7a8b9c0d1).
Finishes the sweep of z9a2b3c4d5e6, which converted the then-remaining opaque
TEXT columns to `CompressedText`. `projects.config` shipped four days earlier
and was missed, leaving it the last plain-TEXT column outside
`conversation_items`. It qualifies on the same terms: machine-generated JSON,
read and written whole with the row, never filtered or ordered in SQL. The
Python type stays `str | None`, so the store, entity, and routes are
unchanged, and no backfill is needed — the codec reads legacy unframed values
and re-frames each on its next write.
Co-authored-by: Isaac
Signed-off-by: aravind-segu <aravind.segu@databricks.com>
---------
Signed-off-by: aravind-segu <aravind.segu@databricks.com>
* docs(prioritization): add issue-prioritization-v2 design + scoring dry-run
The open-issue queue is ordered by a priority label that has lost its
meaning: 60% of open bugs are P1-high, P0/P3 are vestigial, and open-issue
age is flat across priorities — so priority no longer pulls anything to the
front. Feature requests default to P2 by rule, so a high-severity capability
gap (e.g. #2125) is indistinguishable from a trivial nice-to-have.
This adds a design doc and a runnable dry-run:
- designs/prioritization/issue-prioritization-v2.md — evidence from the
current backlog, a re-calibrated priority rubric (with a "P1 is a scarcity
signal" guardrail), a harness-tier axis derived from areas.json, a
composite score (severity x reach x tier + bounded demand + recency +
manual pin) as advisory ordering on top of the labels, and ongoing-
adjustment levers (weekly re-score, manual pin, re-gradable severity).
- designs/prioritization/score_prototype.py — reads an issues snapshot and
prints a before->after ranking with per-issue rank deltas, so weights can
be tuned against real issues. Demand is type-split (multiplier for FRs,
capped tiebreak for bugs), grounded in the 93%-zero reaction distribution.
The prototype grades severity with regex for reproducibility, and its own
false positives ("sandbox bypass" FRs, a bot audit issue) are the doc's
evidence that production severity must be LLM-graded by the existing
tool-less triage classifier.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): address review — grade FRs, tier labels, readiness/dup axes, drop pin
Addresses PR review feedback:
- Grade FRs across all priority buckets (not defaulted to P2); an FR's
priority comes from the severity/reach of its absence. Rubric now applies
to bugs and FRs alike.
- Split comp:harnesses via tier labels (comp:harness-t1/-t2/-t3) mapped in
areas.json, preferred over per-harness labels for future-proofing.
- Add Axis 5 (duplicate reach: N dupes = N reporters = blast radius, +15%
each capped +50%) feeding off the dedup labeler (#4037); do NOT auto-close.
- Add Axis 6 (readiness: repro/body present -> small bump, needs-info ->
penalty) so actionable tickets surface above vague ones at equal severity.
- Drop the pin:high/low lever as over-engineering; maintainers re-grade
severity to bump, the one knob they already use.
- Add a worked example (data points -> score for #3265) and the severity
grade distribution across the backlog.
- Treat sandbox/security bypass as top-tier severity regardless of reach;
keep sandbox/policies as first-class components.
- Add prioritization-efficiency metric: sum(resolved score) / sum(top-k score).
- Use the MAINTAINER file (36 authored) rather than author_association for the
internal/community split; clarify the 128-open-P1 vs 125-P1-bugs figures.
- Fix inert uppercase severity regexes in the dry-run (CVE/RCE/PAT were never
matching lowercased text); document the 25-vs-30 default severity.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): add priority-label regrade preview + mechanism
Adds the backfill view reviewers actually need — the priority *label*
regrade, distinct from the score/rank before->after already in the doc.
- New "How regrading works" subsection under Axis 2: the two regrade
situations (one-time backfill; ongoing on-demand relabel), the mechanical
severity x reach -> bucket mapping, a before->after label distribution
(P1 60% -> 25% of open bugs), and per-move examples with the regex-grader
caveat.
- score_prototype.py gains regrade() + a --regrade mode that prints the
current-vs-regraded label distribution and the changed-label breakdown, so
the backfill preview is reproducible.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): document component-label recommendations
Adds a "Component taxonomy" subsection with the bar for a new comp: label
(filter on it, or it changes grading) and a per-label verdict table:
- Recommend adding comp:sandbox (carved from comp:runner, ~29 issues,
security-grade) and comp:mobile (carved from comp:web-ui, ~23 issues,
distinct domain); defer comp:desktop.
- Leave comp:server/tui/infra/repr/policies as-is with rationale.
- Prefer narrow comp:sandbox over a comp:security umbrella (which would
re-create a mega-bucket from credential/auth issues).
Trims the Sandbox section's component bullet to reference this, and updates
the rollout to add the labels + backfill.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): add full top-200 ranking appendix; tighten prose
- Appendix C: full composite-score ranking of the top 200 of 360 open issues
from today's snapshot (score, re-graded severity, current label, rank delta,
linked issue). Reproducible via a new `--markdown [N]` mode in
score_prototype.py.
- Tighten the Community-demand and Ongoing-adjustment sections (removed
repetition of the drop-pin rationale and the reaction-distribution recap)
without dropping any detail.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): add maintainer guide for hand-correcting the ranking
- New "Maintainer guide — hand-correcting the ranking" subsection: the one
knob (priority label), why corrections are sticky (triage fires on opened
issues only, never overwrites edits), a when-to-correct table, and — per the
"10% is fine" bar — an explicit escalation from per-issue editing to prompt/
weight tuning when the same misgrade recurs or the correction rate crosses
~10%. No per-issue score override, so the ranking stays explainable.
- Reframe Appendix C header as "illustrative, not actionable": call out that
the regex grader puts #2057/#2054 above the real P0 and that scores tie in
coarse bands (~8 tiers, not 200 ranks).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): reconcile Serena's review
Addresses Serena's inline review (and the Pat/Serena thread resolutions):
- Priority vs score: spell out the three-layer flow (axes -> severity ->
score -> priority label). Label is the actionable outcome; score is the
continuous ordering and the reason for the label.
- P0 is now an explicit named list (cannot start; critical API broken; db
migration/data loss; security escape), not a blanket "security". Drop
"all-users-down" (we don't run a hosted service). Add a tier-1 -> at-least-P1
floor as a sanity check.
- Harness tiers backed by activity data: Pi moves to T2 (3rd most active,
above cursor; delegated check), opencode flagged as the marginal T2/T3 call.
- Age is neutral by default (an unfixed old bug shouldn't decay; escalate
instead). score_prototype gains age_factor()/DECAY_OLD; the top-200 appendix
is regenerated accordingly (#61 shifts 19->9, etc.).
- needs-info vs partial info: needs-info = incomprehensible -> no priority, no
reviewer; partial-but-serious -> still prioritized, just no readiness bump.
- Component taxonomy: go granular per review — add comp:sandbox, comp:mobile
(with desktop/iOS/Android device tags), comp:auth (with auth types), plus a
sub_area tag (SDK/native, UI surface, runner phase) so finer routing doesn't
require dozens of flat labels. Intake + rollout updated to match.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): define score -> priority derivation (single system)
The doc previously described two inconsistent score->priority mappings: Layer 3
said the label is "where the score lands" (score -> label), while "How
regrading works" mapped severity x reach -> label independent of the score, and
no actual score->priority thresholds existed. Resolve to one derivation.
- Add explicit score thresholds: >=100 P0, >=60 P1, >=25 P2, else P3. Cut-points
sit at the severity band values, so a multiplier (tier/reach/dup/readiness/
demand) is what lets an issue cross up a band. On the snapshot: P0 9 / P1 58 /
P2 206 / P3 87, a 22% P1-bug share.
- score_prototype.py: replace regrade() (severity x reach) with
priority_from_score() using P0_MIN/P1_MIN/P2_MIN constants; keep `regrade`
as an alias. --regrade now reflects the thresholded labels.
- Reconcile the tier-1 "floor" as a grading heuristic (grade tier-1 bugs >=high,
which clears P1 via the normal path) rather than a label override that would
contradict the single derivation.
- Fix the worked example (#3265) to its real computed factors (reach 1.5,
readiness 1.0, score 126 -> P0) and refresh the backfill table/transition
examples to the thresholded numbers.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): regenerate appendix table with derived-priority column
The top-200 appendix showed only the current label ("Now"); it didn't show the
priority the new score->label thresholds assign. Add a "Derived" column (with a
⚑ flag where it differs from today's label) so the appendix doubles as the
per-issue backfill preview — the ⚑ rows are the relabels the one-time regrade
would apply (103 of the top 200). Regenerated from the same snapshot the rest of
the doc cites, and updated the Appendix C header to explain the new column.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): guarantee the bot never overwrites human priority
Now that priority is a computed output, the re-score/backfill jobs could clobber
a maintainer's deliberate P0->P2 or P3->P1. Add an explicit human-override guard
so that never happens:
- New "Human priority always wins" subsection: a bot-written priority is a
default, a human-written one is a decision. The bot sets priority only where
none exists or where the bot itself set the prior value; a human edit is
detected (bot-priority:* shadow label, or the issue-events actor as fallback)
and skipped — at most surfaced as bot/human disagreement in the ranked view.
- Re-score reads (for ordering) but does not relabel human-owned rows.
- Fix the "corrections are sticky" claim, which previously leaned only on the
on:opened trigger (true today, but the v2 re-score/backfill DO re-run and
write labels) — now it points at the guard.
- Thread the requirement into the Goal, the backfill step, and Rollout step 4
(scoring job MUST implement the guard).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): mark rollout as not-yet-implemented
The design specifies new labels (comp:sandbox/mobile/auth, harness tiers),
areas.json wiring, prompt changes, and a scoring job — none of which are built.
Add an explicit "Status: none of this is built yet" note to the Rollout so the
doc is not mistaken for shipped work; each step is a follow-up.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(prioritization): unify component importance into one telemetry-seeded weight
Importance was a harness-only axis: score_prototype's tier_mult() boosted
comp:harnesses (1.4/1.1/0.9) and left every other component at a flat 1.0, so a
comp:server bug couldn't be weighted above a comp:repr one. And the harness
tiers were seeded from GitHub issue/reaction counts, not real usage.
Unify it into one per-area weight, seeded by telemetry where we have it:
- areas.json: add `weight` (bands 1.4/1.1/1.0/0.9) + `weight_source` to every
area. Harness weights are telemetry-seeded from LJ Sessions by Harness
(claude/codex 1.4; pi/opencode/cursor/antigravity/hermes/copilot 1.1;
goose/kimi/kiro/qwen 0.9 — note telemetry lifts hermes above its GitHub
signal). Non-harness weights are editorial (core server/runner 1.1; mainline
ui/policies/tui 1.0; repr/infra 0.9), honestly labeled weight_source:editorial
since there's no per-component usage signal.
- areas.test.js: assert weight ∈ allowed bands and weight_source ∈
{telemetry,editorial} for every area.
- score_prototype.py: replace tier_mult() (harness-only, title-keyword guess)
with area_weight() that reads areas.json — resolves a harness issue to its
specific harness area, else takes the max weight among the issue's comp:
labels. Drops the TIER1/TIER2 title lists.
- Doc: rewrite Axis 3 as unified Component weight (was Harness tier); update the
score formula, worked example, backfill preview, and regenerate Appendix C.
The unified weight lifts core-area bugs, moving P1-bug share 22%→27% — noted
as intended, with P1_MIN as the lever if we want it stricter.
This is the design + prototype + the areas.json weights themselves; label
creation and wiring areas.json into the live classifier remain rollout
follow-ups.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): consistency pass — fix drift, trim repetition
Full read-through after the unified-weight change. Corrections + trims:
- Fix drift the incremental edits left: "harness-tier" → "component weight" in
the Goal, Layer-2, and Intake; the Rollout "Status" no longer claims
areas.json is unchanged (it now carries the weights).
- Refresh the Dry-run before→after tables to the current component-weighted
ranks (#2125 rank 1, #16 rank 7, #3557 rank 10, #61 rank 15, …); the stale
ranks predated the weight change.
- De-duplicate the regex-false-positive story: it was told four times (Axis 4,
backfill caveat, Dry-run limits, Appendix C). Keep the Dry-run "limits" table
as the canonical telling; Axis 4 and the caveat now point to it.
- Collapse the Sandbox section's component bullet (it duplicated Component
taxonomy) into a pointer; keep the evidence + the P0-severity rule.
Net −14 lines of prose, no content lost; Appendix C table unchanged.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): score in one Databricks job; persist severity; determinism
Rework the scoring/triage architecture per review discussion so the score is
computed in exactly one place, and document reproducibility.
- Scoring job is a scheduled Databricks NOTEBOOK, not a GitHub Action. New
"Surfacing the score" section: reads the already-synced
main.team_eng_omnigent.github_issues_bronze table (reads are tokenless),
computes the score once, writes an issue_scores Delta table the dashboard
reads, and applies labels back to GitHub (the one credentialed step, via a
Databricks secret). Preserves the prompt-injection boundary and flags the
scheduled-vs-dispatch-Action latency decision for the team.
- Persist severity (Rollout step 1): graded once at triage and stored, since
it's the largest multiplier and can't be recomputed from labels/text — this
is what makes re-scoring deterministic.
- New "Determinism" section: pure-arithmetic score is reproducible given
persisted severity; demand/dup are intended bounded time-varying inputs;
tie-breaking deferred (ORDER BY score DESC, issue_number when wanted).
- Human-override guard now keyed on an issue_bot_state Delta table (also the
job's idempotency record against bronze ingestion lag), replacing the
bot-priority shadow-label sketch; stickiness no longer leans on on:opened.
- Linear: already synced regularly; scores stay in GitHub + dashboard, not
pushed to Linear.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(prioritization): S0-S3 severity, reach folded in, age axis, restructure
Reworks the design around the axes → severity → score → priority mental model
and tightens the doc.
- Severity is an S0-S3 grade the LLM gives from issue CONTENT; reach is folded
into the grade (no separate reach multiplier). Severity must not re-encode
factors weighted elsewhere (component). Soft claude/codex nudge, not a floor.
- Component weight (Axis 3): filled the weight table + combining rule (max),
bumped server/runner core to 1.2, documented the new labels
(comp:harness-t*, comp:sandbox/mobile/auth) and their inherited weights.
- Age promoted to its own axis (0-5d 1.0 / 5-21d 1.2 / 21d+ 0.8); Determinism
section reconciled (age is intended over-time drift, not neutral).
- score_prototype: drop reach(); age_factor bands anchored to the snapshot's
newest issue; areas.json weight 1.2 added + allowlisted in areas.test.js.
- Dry-run section replaced with an LLM-vs-regex comparison over the 100 oldest
open issues (distribution + confusion matrix; 49/100 flip), regenerated
Appendix C, and trimmed Intake/Rollout/Metrics (Rollout is now action items).
Nothing here is wired into the live classifier yet; areas.json weights + test
are the only runtime-adjacent change. Rollout lists the follow-ups.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs: reconcile prioritization scoring review
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs: simplify issue demand scoring
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
`test_historical_image_source_block_is_replaced_with_compact_placeholder`
imports `_render_prior_content`, but the function is named
`_render_prior_content_blocks`. The ImportError raised at class scope crashes
the pytest-xdist worker outright (`AttributeError: 'tuple' object has no
attribute 'value'` inside pytest's unittest plugin), so the whole
`Pytest (inner-rest)` shard fails with an INTERNALERROR rather than a normal
test failure.
Use the real name, and join the returned content blocks via the existing
`_text_of` helper since it returns blocks rather than a string.
Verified the test still guards the behavior it was written for: disabling the
base64 `source`-block arm of `_redact_inline_base64` makes it fail, and
restoring it makes it pass. Full file: 122 passed (was 1 failed + worker crash).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
omnigent-telemetry#15 introduces a CloudFront default config
(omnigent_version: "default") served for any version that lacks an
explicit config file. Without this change, the version check on line 190
always rejects the default payload and silently disables telemetry.
Accept "default" as an equivalent of the current VERSION so the default
config is honoured.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
The historical-replay redaction (_redact_inline_base64) only matched
whole-string "data:*;base64,..." URIs — the resolver form under
image_url / file_data. But Claude Code's Read tool returns an image file
as an Anthropic content block {"type":"image","source":{"type":"base64",
"data":"..."}} — raw base64 with no data: prefix — carried in a
function_call_output. That shape slipped past redaction, so if it reached
the "Conversation so far:" text prefix json.dumps flattened the full
base64 into prompt text (the same class of overrun that wedges resume on
the native path).
Extend _redact_inline_base64 to also rewrite image/document base64
"source" blocks to a compact "[image/attachment: <media>, <N> base64
chars]" placeholder. Verified: image and document source blocks now
redact (base64 absent), data-URI and plain-text paths unchanged.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
The per-turn idle watchdog fails a turn that emits no non-heartbeat
events for the window. Context compaction's summarizing LLM call runs
as a single long await that emits nothing until it returns, so on a
near-full context it can exceed the 240s default and trip the watchdog.
That wedges the session in a "Prompt is too long" -> compaction ->
240s-timeout loop, since every retry re-triggers the same slow compaction.
Raise the default from 240s to 600s so a healthy long compaction has
room to finish. The HARNESS_TURN_TIMEOUT_S env knob and the absolute
ceiling are unchanged.
Co-authored-by: Isaac
* fix(web): persist open shell tabs per session
Shell tabs lived only in transient component state and the
conversation-switch effect cleared them on every navigation, so opening
a shell, switching sessions, and returning lost the tab. The PTYs
themselves live on the server and are re-fetched by useTerminals — only
the tab strip was being discarded.
Persist openTerminals/selectedTerminalKey per session in
sessionWorkspaceState (mirroring the open file tabs), seed and restore
them on mount/switch, and gate the dead-tab prune effect on the
terminals list's loading state so a restored tab isn't wiped by the
transient empty list before the session's terminals load.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e): cover shell-tab persistence; skip prune on errored terminal fetch
Add an e2e_ui test that opens a real shell in one session, switches to
another via the sidebar (client-side nav), and returns — asserting the
shell tab and its live PTY are restored. This exercises the
conversation-switch effect that regressed, which a full page reload
wouldn't.
Also address review feedback: the dead-tab prune effect ran whenever the
terminals query wasn't loading, but an errored fetch also yields an empty
list — a non-authoritative one. Pruning against it would wipe restored
tabs whose PTYs we simply couldn't reach. Gate the effect on
terminalsError as well, with a component test for the errored-read case.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Claude Code >= v2.1.197 writes `status: "shell"` to its per-session status
file when a turn ends but a background shell is still alive. The status-file
poller's map didn't know that literal, so `read_session_status` returned
`None`, the poller fired no edge and stayed stuck on its last `running` (while
also suppressing the PTY watcher's `idle`). The session never reported idle
while a background shell ran, so `sessionStatus` stayed `running`,
`shouldQueueSend` returned true, and every new message queued client-side —
regressing the "don't queue while only background work runs" behavior.
Map `shell` to `idle`: the agent loop is idle, and the Stop hook separately
relabels its own `idle` to `waiting` with the shell tally, which is what keeps
the "N background tasks still running" spinner lit.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
This reverts commit 617293d3d9.
Painting a cached transcript before revalidation meant the contents
moved under the reader: the window appeared instantly, then shifted as
newer commits were gap-bridged onto it. A hydrate spinner that resolves
into a settled transcript reads better than a fast paint that jumps, so
go back to the cold-load spinner on every conversation switch.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* ci: mirror linked issue priority onto closing PRs
Add a workflow that copies an issue's priority label (P0-P3) onto the
PR that closes it. Only closing links (closes/fixes/resolves #n) count;
a plain "related to #n" mention is ignored. When a PR closes several
issues the highest priority wins, and stale priority labels are dropped.
Runs on PR events and re-syncs when an issue's priority label changes;
the issue-label trigger is gated to priority labels only so other label
edits don't spin up the job.
Co-authored-by: Isaac
* ci: address review feedback on priority sync
- Tolerate null GraphQL nodes (unknown PR number, data: null) instead of
crashing on AttributeError; cover the parsing with tests.
- Add a 30s urlopen timeout so a stalled connection fails fast.
- Validate PR_NUMBER is an integer with a clear message.
- Surface a warning when the issue->PR GraphQL lookup fails rather than
silently succeeding.
- Pass the resolved PR list through an env var instead of interpolating
it into the run block.
Co-authored-by: Isaac
Rename the `enhancement` label to `Feature` and `documentation` to `Docs`
across the issue-triage system. The triage agent's `type` value is applied
verbatim as an issue label, so update the validator allow-list, the agent
schema and classification rule, the feature-request template's auto-label,
and the design proposal doc to keep them coherent.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* perf(host): cache auth headers and parallelize status payloads
Two follow-on speedups for omni host status:
1. Cache _remote_headers() per base_url within a process.
Databricks SDK credential resolution (~3s) ran on every
_host_http_json call. Since tokens are valid for the lifetime
of a CLI invocation, resolving once and reusing is safe.
A threading.Lock serialises concurrent first-time resolution
for the same URL.
2. Build daemon status payloads in parallel with ThreadPoolExecutor.
With the dead-process skip from the previous commit, only live
daemons make HTTP calls. Parallelising them lets independent
servers be queried concurrently instead of sequentially.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* chore: restore uv.lock to main
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: move header cache resolution inside try/except in _host_http_json
_remote_headers() does file I/O and Databricks SDK calls that can raise
OSError. The cache-populating call was outside the try block, so such a
failure propagated unhandled. Under ThreadPoolExecutor (added in this
PR) that aborted the entire omni host status listing.
Move the resolution inside the existing try/except so auth/file errors
remain recoverable and produce a status_code=0 result per daemon,
matching the pre-change behaviour.
Also adds test_host_http_json_handles_remote_headers_oserror to pin
this contract.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* chore: fix import order (ruff)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* ci(triage): re-triage issues when needs-info is cleared
Add a hybrid needs-info lifecycle. When the issue author comments on an
issue that still carries needs-info, needs-info-response.yml removes the
label using the omnigent-ci App token (the default GITHUB_TOKEN would not
re-trigger downstream workflows). That removal fires issue-triage.yml's
new `unlabeled` trigger, which reads the reporter's follow-up comments,
reclassifies, and assigns an owner — re-adding needs-info only if the
issue is still too vague. Issues the reporter never clarifies are closed
by the existing stale.yml.
issue-triage.yml changes:
- trigger on issues [opened, unlabeled]; the unlabeled path fires only
for needs-info on an open issue, and allows a bot actor (the App)
- feed the author's follow-up comments into the triage prompt
- remove needs-info on re-triage when the LLM no longer flags it
- suppress the duplicate-of comment on the re-triage path
- add a per-issue concurrency group
Co-authored-by: Isaac
* ci(triage): address review — idempotent label removal, dormant-App notice
- needs-info-response.yml: re-check live labels before `gh --remove-label`
so a stale event payload / race can't fail the step (gh errors on a
missing label); emit a ::notice:: when the omnigent-ci App is
unconfigured so a dormant feature is distinguishable from a broken one.
- issue-triage.yml: also suppress the `duplicate` label on the re-triage
path (not just the comment), keeping the label and its explanation
consistent; hoist `import os` to the top of the block.
Co-authored-by: Isaac
* feat(webui): capture raw SSE events and show in execution logs panel
- sseEventLog.ts: module-level ring buffer (max 500 events/session)
with subscribe/snapshot API for useSyncExternalStore
- useSseEventLog.ts: React hook that subscribes to the ring buffer
- chatStore.ts: tap tapSessionEvents to push each StreamEvent into the
ring buffer; clear on fresh stream bind (not reconnect)
- ExecutionLogsPanel.tsx: add Items/SSE toggle — SSE tab shows
timestamped raw events with expand-to-pretty-print, auto-scrolls
to bottom as events arrive
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* perf(webui): skip SSE ring buffer when debug mode is off
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* perf(webui): cache isDebugMode as module-level boolean
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(webui): return new array ref on push so useSyncExternalStore re-renders
Object.is on the same mutated array always returns true, causing React
to skip re-renders. Produce a fresh array on every push/trim so the
snapshot reference changes and the SSE list updates in real time.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(webui): support localStorage debug flag in addition to ?debug=1
Both useDebugMode and the SSE ring buffer guard now check
localStorage.getItem("debug") === "1" as a fallback, so debug mode
can be toggled once in the console without keeping ?debug=1 in every URL:
localStorage.setItem("debug", "1") // enable
localStorage.removeItem("debug") // disable
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(webui): stable snapshot ref and correct debug flag detection
- snapshotSseLog: return shared EMPTY constant instead of allocating a
new [] on every call; prevents useSyncExternalStore render-loop from
the unstable reference on sessions with no log yet
- isDebugMode: re-read window.location.search + localStorage on every
call instead of caching against popstate; React Router uses pushState/
replaceState which never fires popstate, so the cached value stayed
stale when navigating to ?debug=1 in-app
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): split the harness picker by support level
The landing composer's harness picker split its primary list and "More"
group by host readiness, so any configured harness led: Claude Code,
Codex, Cursor, and Pi all competed for the few primary slots, while "More"
held only harnesses that happened to need setup. Support level — what
actually distinguishes these integrations — wasn't represented at all.
Add a `fullySupported` flag to `NativeCodingAgentSpec` and set it on
Claude Code and Codex, the integrations we maintain and test end to end.
Only those lead; every other harness folds into "More" whether or not it
is configured on the host. The flag is opt-in, so the supported set is two
lines in one file rather than a marker on each of the nine others, and a
test asserts the set is exactly claude + codex so it can't drift silently.
Two behaviors are preserved: selecting a harness pins it inline via the
existing `effectiveAgentId` rule, so the active pick is never buried; and
the hide-unconfigured preference still outranks support level, dropping
harnesses that can't launch here (and the "More" trigger with them when
that empties the group).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(web): promote previously-launched harnesses in the picker
Splitting the picker by support level left Pi and Cursor users a hover
away from their harness on every new session, even though the split is
right for a first-time user. Nothing recorded which harnesses someone
actually launches.
Add a localStorage-backed `useRecentHarnesses` (modeled on
`useRecentWorkspaces`, but not host-scoped — a preference for Pi follows
the person across machines) and record the canonical harness id on a
successful create. The picker then promotes any recorded harness into the
primary list alongside the fully supported ones, so a regular Pi user
gets one click instead of one hover, while a fresh install still leads
with Claude Code and Codex only.
Recording happens only after the create succeeds, so a harness the user
merely browsed past never earns a slot, and the hide-unconfigured
preference still outranks recency: promotion applies within what can
launch on the host, never resurrecting a harness that can't run there.
Stored ids fold through the reversed-alias map, so `native-pi` matches
the canonical `pi-native` spec.
Also fixes the two CI failures from the support-level split: the flow
test's `selectAgent` helper now drills into "More" only when the row
isn't already inline, and the harness-install e2e no longer drills for
Codex (fully supported, so it leads inline even while needing setup).
Adds tests/e2e_ui coverage for both behaviors, stubbing every harness as
configured so the split is provably driven by support level rather than
host readiness.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(webui): wire SessionRail into AppShell behind ?debug=1
SessionRail and ExecutionLogsPanel were implemented but never rendered.
Add SessionRail as a desktop-only column between the chat and workspace
panel, gated on debugMode so it only appears with ?debug=1. The column
hides automatically when a push panel (terminals or execution logs) is
open.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(webui): remove TerminalsCard from SessionRail debug rail
Terminals are already shown in WorkspacePanel. The debug rail should
only show the Execution logs card. Also removes the onExpandTerminals
prop and all terminal-related dead code from SessionRail.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(webui): fix execution logs card title overflow in debug rail
Widen the debug column from w-48 to w-56 and add truncate/min-w-0 to
the CardTitle so the text doesn't overflow into the action buttons.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(webui): add top padding to debug rail column
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): keep chat content clear of the TurnRail as the area narrows
PR #4085 replaced the transcript's md:pl-12 left inset with a symmetric
px-4 gutter, dropping the clearance that kept the centered chat column off
the left-edge TurnRail (the tick minimap). On a narrow conversation area
the prose crowded the ticks.
Restore the clearance as a continuous, width-driven clamp keyed on the
conversation area (@container/chat) rather than the viewport: the column
slides left with the area until its edge nears the rail, then the left
inset ramps up to hold a minimum gap and caps at 3rem so it stops moving
instead of snapping. Because it reads the area width, opening the sidebar
feeds it too.
Add a multi-turn visual-snapshot test that mounts the rail (it only renders
for >= 2 turns, so the one-turn baseline never covered it), rendered at a
narrower viewport so the inset is actually engaged in the capture.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e-ui): regenerate visual baselines
* fix(web): shrink rail gap to 24px and stop the pill leaking into snapshots
Reduce the restored TurnRail clearance cap from 3rem to 1.5rem (24px) so the
column sits closer to the ticks while still clearing them.
Park the pointer out of the transcript's top hover band before capture in both
chat snapshot tests. Playwright's virtual mouse starts at (0,0), inside the band
that reveals the "Jump to top" pill (and, on the rail test, over a tick), so a
load-timing race could flash that transient chrome into the resting-state
baseline. Moving the pointer low pins it hidden.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e-ui): regenerate visual baselines
* test(e2e-ui): hide the Jump-to-top pill from chat snapshots deterministically
The pill is transient chrome: the initial layout settle (LatestTurnSpacer +
StickToBottom pinning to the bottom) fires a scroll that reveals it for ~2s, so
whether it lands in a capture is a race — which is why a regenerated baseline
picked it up. Force it hidden via an injected style, the same way the shared
settle kills the blinking caret, so the resting-state baseline is deterministic
regardless of when the scroll settles.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e-ui): regenerate visual baselines
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
- PiExecutor._resolve_model: strip trailing [1m]-style bracket suffixes before
passing model IDs to the Databricks AI Gateway. The direct Anthropic API
accepts e.g. system.ai.claude-opus-5[1m] but the gateway endpoint does not
(returns 404).
- CodexExecutor.run_turn: when model_provider_override is set (cli-config path)
pass model=None to thread/create so the codex binary uses its own configured
model rather than forwarding an unresolvable alias (e.g. gpt-5.6) to the UC
API.
- credential_label: cli-config providers now label from the entry name
(provider_display_name) rather than the display_name field, for consistency
with other provider kinds.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* Root cause fix — omnigent/policies/builtins/_shell.py
sudo/env/command/time/exec moved out of CMD_WRAPPERS (skip-one-word) into _FLAG_WRAPPERS with their value-consuming flags. CMD_WRAPPERS is now just {"nohup"}, which genuinely takes no options. -- needs no entry — it's consumed as a valueless flag.
While verifying, I found the same hole one level down, which also affects the original GHSA-fixed wrappers: _skip_flag_wrapper_args matched value flags by whole-token equality, so bundled short options bypassed too — sudo -nu root git push, env -iu FOO git push, and (pre-existing) nice -qn 10 git push. It now scans the bundle's characters and consumes a separate value only when the value-taking option is the bundle's last character, so -n 10/-o L still consume while -n10/-oL stay attached. This mirrors orchestration.py:236-245, which already got this right for blast_radius.
Fail-safe backstop — new is_unresolved_invocation(), wired into both consumers
The wrapper tables are an enumeration, so I didn't want the next unmodelled wrapper to be another silent ALLOW. A head still starting with - now routes through each policy's existing "can't parse this" path rather than abstaining — ASK in github.py, the configured action in working_dir.py. Reachable today via nohup -- git push …. Detection is shared; the response stays per-policy, per the module's stated contract.
* 1. env -S / --split-string (the blocker). Reviewer was right: modelling -S as a value flag swallowed the command into the flag's value, leaving zero tokens — which is_unresolved_invocation([]) can't see. Fix takes the reviewer's option (b): env -S is a command interpreter like sh -c, so it's unwrapped and re-parsed on the path that already exists for bash -c / eval.
- _skip_flag_wrapper_args gained a capture_flags set and now returns (index, captured) — reusing the existing flag walk (which already handles --flag=v, -S v, -Sv, bundles like -iS v) instead of writing a second scanner.
- real_invocation_tokens stops at env when a split-string is captured; unwrap_shell_command returns it → recursion gates the inner command.
env -S 'git push <evil> main' → DENY. env -S 'npm test' → still abstains.
2. /usr/bin/sudo -u root git push — same fail-open, not flagged in either review. Wrapper lookup matched the bare word only, so a path token became the apparent command and the segment abstained → ALLOW. Wrappers now match on basename (unwrap_shell_command already did).
* fix(policies): add BSD sudo -a/--auth-type and -c/--login-class to value-flag set
These two options were missing from _FLAG_WRAPPERS["sudo"], leaving a
residual silent-ALLOW bypass: sudo -a foo git push ... left "foo" as
the apparent command head, which does not start with "-" so is_unresolved_invocation
could not catch it. Add both flags and tests for each form.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
test_scheduled_task_create_edit_modal_and_time_picker flaked ~30% of runs,
always timing out on `_pick_minute`'s `name_input.click()` with
"dialog-overlay intercepts pointer events". While the time-picker Popover is
open, the Radix Dialog owns pointer hit-testing over the modal, so a normal
actionability-gated click at the input's coordinates resolves to the overlay
and blocks the full 30s under load.
Force every dismiss click on the name input (`click(force=True)`) — the same
technique the picker's open click already uses. A forced click still
dispatches a real pointerdown on the input, which Radix registers as the
interaction-outside that closes the popover, without waiting on overlay
actionability. Covers all three dismiss sites: the retry path and final
dismiss in `_pick_minute`, plus the two post-typed-time blurs in the test body
(focusing the time input reopens the picker via onFocus).
Verified: reproduced the flake (multiple failures across batches of 5-8 runs),
then 12/12 green after the fix; the full file's 9 tests pass.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* refactor(web): decouple typography from interface geometry
Make the desktop font preference drive semantic text tokens while keeping icons, controls, and spacing fixed.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* refactor(web): migrate interface body text to text-ui
Use the settings-controlled semantic body token across shared components and application pages for consistent sizing.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* style(web): refine sidebar typography and empty states
Align sidebar hierarchy with settings-controlled tokens and make empty projects easier to scan.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* style(web): tighten sidebar density and theme polish
Unify sidebar row geometry, refine theme-specific colors and canvas treatments, and standardize compact controls.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* test(e2e-ui): align font size checks with typography tokens
Update browser assertions for the discrete desktop font token and its current bounds.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* test(ui-snapshot): update typography visual baselines
Adopt the CI-rendered snapshots for the intentional settings-driven typography changes.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* fix(web): preserve dark active sidebar hover
Keep selected row colors stable when hovering in dark mode across both sidebars.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* style(web): polish sidebar actions and overlays
Align sidebar controls, dropdowns, and tooltips with shared density, typography, and interaction tokens for a more consistent visual hierarchy.
* style(web): normalize mobile sidebar scale
Keep mobile sidebar typography and icon geometry predictable without changing the desktop presentation.
* style(web): refine responsive sidebar and chat density
Use responsive sidebar spacing and settings-driven chat typography so mobile and desktop retain clear, consistent reading rhythm.
* test(web): align CI expectations with sidebar polish
Update E2E assertions and reviewed visual baselines to reflect the intentional typography, navigation, and density changes.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
---------
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* dev/repro-agent: pin the verdict handoff to a single JSON block
The output contract only said "a single structured verdict block" without
pinning a format, so the agent rendered YAML on some runs and JSON on others,
and the shape drifted (missing facets, prose bullets instead of objects). That
makes the `verdict` field — which the caller parses to label the issue —
unreliable to extract.
Pin it: exactly one fenced ```json block as the final message, JSON only, every
key always present, and `verdict` restricted to the four lowercase literals so
it matches verbatim. `facets` becomes an array of {symptom, verdict, evidence}
objects instead of free-form bullets. README step 4 updated to match.
Co-authored-by: Isaac
* dev/repro-agent: require the JSON block be the last chunk, allow prose above
Some runs split the artifacts into separate markdown sections (a small
"Reproduction Verdict" block, then prose "Journey"/"Facets" headers) with no
single consolidated handoff, so there was no reliable last block to parse.
Clarify the contract: comprehensive prose above the block is fine, but the
```json block must be the LAST chunk of the final message (nothing after its
closing fence) and must carry the complete self-contained handoff. Explicitly
forbid splitting the artifacts across separate sections/headers. There is no
output-schema enforcement for the claude-sdk agentic loop (AgentSpec.output_type
is inert), so this is enforced by instruction plus last-```json-fence parsing on
the caller side.
Co-authored-by: Isaac
The "new session in project" pencil navigates to /?project=<name> while
the landing screen stays mounted. The project-prefill state machine only
restarted when the ?project= param changed, so re-clicking the SAME
project's pencil after editing its default settings kept the stale seeds
— the fix only showed up after clicking another project (or Home) and
back, which flipped the param away and back.
Track a signature of the config the machine last settled from and restart
the prefill when that content changes for the same project, mirroring the
project-switch reset. The saved config is already fresh in the react-query
cache; this makes the machine re-read it.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* Add deep-research example (single agent over an MCP search server)
A single-agent example that answers a question with a cited, cross-checked
report: it plans sub-queries, searches the live web and reads full pages
through an MCP search server, and verifies claims across independent sources.
It is the repo's first example that wires an MCP server via tools/mcp/*.yaml
(auto-discovered), so it also documents the MCP extension path. One agent plus
one MCP server, no sub-agents — the simplest example to copy from. Runs
zero-config against a public, keyless endpoint.
Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
* test: add e2e coverage for the deep-research example agent
The examples-coverage-sync drift guard (test_every_agent_has_a_dedicated_test_file)
requires every example agent to have a dedicated e2e test. The deep-research
example shipped without one, failing E2E Tests (shard 0/4).
Add a structural test via validate_agent_def_structure (infra-free: the agent's
tools come from the hosted Keenable MCP server and it runs on the claude-sdk
harness, so it can't run end-to-end in CI). Because the agent name 'deep-research'
has a hyphen (not a valid Python test-module name), the test lives in
test_deep_research_example.py and the guard is told via a 'deep-research' entry
in _ALT_COVERED, mirroring the existing 'openai-coder' handling.
Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
* docs: show deep research search provider options
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Ilya Bogin <ilya.bogin@keenable.ai>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy
Omnigent's OSEnvironment but left two layers as documented follow-ups: the
delegated I/O was invisible in history and no content policy ran on it.
Wire both onto the existing _handle_fs_read / _handle_fs_write handlers:
- emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in
history (the adapter renders them as observed function_call items)
- run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses
the op (a write is gated before it happens), failing open otherwise
Content-only: the harness policy round-trip carries no request_data, so the
payload is {"result": content}. Closes the file-I/O recording / content policy
item in docs/QWEN_FOLLOWUPS.md.
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(qwen,goose): gate delegated fs at the call phase and audit stale ops
Addresses the review on the delegated-fs recording/policy work.
1. Phase semantics. A delegated write was gated by a result-phase policy eval
before the write, which is content-only and fails open, so a policy timeout
would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with
the tool name, path, and content, failing closed on an eval error or an ASK
verdict (delegated fs has no elicitation path). Reads keep the result-phase
content check that decides whether the read bytes reach the model.
2. Audit records. Stale prior-turn server fs requests were answered at turn
start, running real I/O, and then had their ToolCall events cleared before
they reached history. Drain those events into history instead of dropping
them, so the I/O they performed is recorded.
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(qwen,goose): evaluate result-phase policy after a delegated write
The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a
result-phase evaluation, so the value env.write() returned was never policy
checked and the audit record dropped it. Reads already did both phases.
Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial
records BLOCKED and refuses the response; it cannot undo the write, since it
runs after the operation. The success record now carries the real result too,
matching the read path.
_fs_content_policy_denies was read-specific, so it is now
_fs_result_policy_denies and takes any result. Read behavior is unchanged.
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
PR #3105 removed the `server start` subcommand in favor of
`server --background` and updated the Electron shell-out in the same
commit. The desktop app ships on its own electron-updater channel, so a
client built before v0.7.0 is a normal steady state against a v0.7.0
CLI — and it still runs `omni server start`, which now dies with
"No such command 'start'". "Start locally" is broken for those users.
Restore the subcommand as a hidden alias that routes to the same helper
as the flag, so the two spellings cannot drift. The deprecation notice
goes to stderr; the desktop parses the URL off stdout, which is
unchanged.
Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
Co-authored-by: Serena Ruan <82044803+serena-ruan@users.noreply.github.com>
Remove the cron schedule triggers from both discord-watch-rotation
workflows so they no longer fire automatically. workflow_dispatch is
kept for manual runs, and the original crons are left commented out so
the schedules can be restored later.
Co-authored-by: Isaac
Switching from a Codex session to a Claude Code session briefly painted
the Codex model (e.g. gpt-5.5) in the Claude session's composer before
correcting itself.
`switchTo` clears the session-scoped model fields but deliberately keeps
`selectedModel`, the cross-session sticky pick, so a CLI-created new chat
inherits the user's last choice. The native picker kind flips to Claude
immediately (the session query and sidebar row are already cached), so
for the whole snapshot round trip the composer resolved the sticky and
read the outgoing session's model.
Only surface the sticky once the session's own catalog vouches for it.
Pre-bind the catalog is empty, so the label waits instead of advertising
a model this session would reject; post-bind it is a no-op, since the
store only ever leaves a catalog-compatible sticky (or the override) in
`selectedModel`.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(server): file forked sessions into the source's project
Forking a session filed in a first-class project left the fork unfiled:
fork_conversation built the fork's metadata row without project_id. The
fork route now carries the source's project onto the fork, gated on the
forker owning that project (projects are owner-private, so a fork of a
shared session filed in someone else's project stays unfiled).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): refresh the project folder when a session is forked
A fork inherits the source's project, but the dialog only invalidated the
flat session list — each folder renders its own ["project-sessions", name]
query, which has no poll and converges only on an explicit invalidation.
The push stream can't cover it either: it skips the active session, and the
fork becomes active on navigate. So the clone stayed missing from its folder
until a reload or a re-navigation.
Adds an e2e regression test. It seeds the committed turn the fork action
anchors on straight into the store (new seed_committed_turn helper) instead
of driving a model turn, so it neither waits on nor inherits the flakiness
of the mock-LLM harness.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): stabilize reasoning indicators during active turns
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
A dropped host rendered two different indicators depending on incidental
state. The badge read the host tunnel directly (name + red dot), while
ChatPage passed a separate `hostOffline` prop derived from
`liveness.kind === "host_offline"` that replaced the name with generic
"Host is offline — click to reconnect" copy.
`host_offline` is far narrower than "the host tunnel is down": it also
requires the runner to be down (a live runner short-circuits to `online`),
the startup grace to have lapsed, and the host to be non-resumable. So the
same event — the host dropping — showed a passive, unclickable name when
the runner outlived the host, and a nameless reconnect prompt when it
didn't. The name is what tells the user which machine to go restart.
The badge now owns the decision: one shape (name + status dot) that turns
into a button opening the reconnect instructions whenever its bound host is
offline and reconnectable. A dormant resumable managed host stays passive —
the next message wakes it, so `omnigent host` would be wrong advice.
The reconnect dialog's state now comes from the session's host binding
rather than liveness, so a session whose runner outlived its host gets the
`omnigent host` command instead of the local `omnigent run --resume` one.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Closes#866
The `omnigent` Homebrew formula has not built since 0.7.0, and the tap reported
green anyway, so 0.7.0, 0.8.0 and 0.8.1 all merged with no bottle — every user
compiled from source, and CEL policies silently did not work.
- **Root cause was the CEL migration.** #2970 swapped `cel-expr-python` for
`cel-python` on the premise that it is pure Python. It is not: `cel-python`
hard-depends on `google-re2`, whose sdist runs `bazel build` whenever
`GITHUB_ACTIONS` is set. The bazel dependency moved rather than disappeared.
- **Pin compiled extensions to upstream wheels.** `generate_formula.py` gains
`WHEEL_REQUIRED` / `PREFER_WHEEL` / `PURE_WHEEL` with abi3 and universal2
handling, so grpcio (by far the most expensive build), protobuf, regex,
uvloop, httptools, argon2-cffi-bindings, markupsafe, pyyaml, zstandard and
google-re2 stop being compiled. Native wheels rank above pure-Python ones, so
protobuf keeps its upb build instead of the slow fallback.
- **jiter, tiktoken and watchfiles keep building from source.** Their maturin
wheels carry no Mach-O install-name padding, so Homebrew relocation fails with
"Failed changing dylib ID" (#866). `pendulum` can go neither way — its wheel
cannot be relocated and its sdist does not link on 3.14 (pyo3 leaves
`_Py_NoneStruct` undefined) — so it takes the pure-Python wheel, which ships
no extension module at all.
- **A dropped dependency is now an error, not a warning.** A missing sdist used
to be skipped silently, yielding a formula whose venv lacked an import;
`--allow-no-sdist` is the explicit waiver. The formula test also asserts
`import re2, celpy`, since omnigent imports celpy behind `try/except
ImportError` and would otherwise disable policies silently.
- **Delete `update-homebrew.yml`.** It raced `homebrew-tap-pr.yml` on the same
`release: published` event and asserted on hand-maintained stanzas the
template no longer emits, so it failed on every run. Its one worthwhile part
moves into `homebrew-tap-pr.yml`: an admin/maintain gate on manual dispatch
(it writes to another repo with an App token), plus
`persist-credentials: false`. Its nightly `schedule` is deliberately NOT
carried over -- that cron only existed because `brew
update-python-resources` resolves through pip's `--uploaded-prior-to=P1D`
window and so could never see a same-day release. The generator runs `uv pip
compile --no-config` straight against PyPI, so the blindness it worked around
no longer exists, and a nightly regeneration would just burn a runner to
print "nothing to do".
Verified by building the generated formula in the tap, not by inspection.
- `omnigent-ai/homebrew-tap#18` contains **verbatim output of this
`generate_formula.py`** and bottled successfully on macos-15 and macos-26
(run 30944428771, `bottles_macos-15` / `bottles_macos-26` ≈ 37 MB each). This
is the check that matters: it proves the generator — not a hand-edit —
produces a buildable formula, so the next release regenerates something that
works.
- `omnigent-ai/homebrew-tap#17` carries the same fix for the shipped 0.8.1
formula and is green on all three runners, with `brew test` running
`import re2, celpy`. Inspected the bottle: `celpy/__init__.py`,
`re2/_re2.cpython-314-darwin.so`, and a relocated
`jiter/jiter.cpython-314-darwin.so`.
- Audited every pinned wheel by replaying Homebrew's own operation,
`install_name_tool -id <Cellar path>` against each extracted `.so`, so the
wheel/source split is evidence-based rather than guessed.
- `python3.12 -m py_compile`, `ruff check`, `ruff format --check`, `brew style`
(no offenses), `ruby -c`, plus stubbed-PyPI unit checks of the new failure
paths (missing sdist is fatal, `--allow-no-sdist` waives it, abi3 accepted,
free-threaded `cp314t` rejected).
- Confirmed generator output matches the green formula: same 100 resources,
identical sdist/wheel split, no non-comment differences.
N/A — release tooling, no user-visible UI.
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
The generator has no test suite in this repo, and its real contract — "the
emitted formula builds under Homebrew on macOS" — cannot be asserted here. It is
covered instead by building the generated formula on the tap's `brew test-bot`
matrix (homebrew-tap#18, bottles produced on macos-15 and macos-26). The two new
generator failure paths were exercised locally against stubbed PyPI metadata,
and every wheel pin was verified relocatable with `install_name_tool`.
`brew install omnigent` works again, and installs prebuilt wheels instead of
compiling grpcio and friends from source.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
The boxlite SDK's BoxOptions already supports disk_size_gb, but the
omnigent wrapper never threaded it through — every box got the SDK's
own default disk size with no way to override it. Add
sandbox.boxlite.disk_size_gb to the server config, alongside the
existing image/env knobs.
Signed-off-by: Avri Chen-Roth <11185446+the-mentor@users.noreply.github.com>
Co-authored-by: Avri Chen-Roth <11185446+the-mentor@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(web): decouple typography from interface geometry
Make the desktop font preference drive semantic text tokens while keeping icons, controls, and spacing fixed.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* refactor(web): migrate interface body text to text-ui
Use the settings-controlled semantic body token across shared components and application pages for consistent sizing.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* style(web): refine sidebar typography and empty states
Align sidebar hierarchy with settings-controlled tokens and make empty projects easier to scan.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* test(e2e-ui): align font size checks with typography tokens
Update browser assertions for the discrete desktop font token and its current bounds.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
* test(ui-snapshot): update typography visual baselines
Adopt the CI-rendered snapshots for the intentional settings-driven typography changes.
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
---------
Signed-off-by: Ajay Alfred <ajayalfred07@gmail.com>
`HostRegistry.deregister` was a bare `dict.pop`, and the host tunnel's receive
loop refreshed `conn.last_frame_at` without checking whether its connection was
still the registered one. The runner side already guards both (
`TunnelRegistry.deregister` takes a session guard and `mark_frame_seen` rejects a
superseded session); the host side did not, so a host could be left in a state
its own route handler never noticed.
Dropping a host registration from outside the route handler did not close the
socket or cancel its tasks. The ping loop kept writing `host_store.heartbeat`, so
the durable row stayed **online** while every `host_registry.get` reported the
host offline. Anything that resolves liveness from that row then waits for a
reconnect the host was never told to make, because from the host's side nothing
happened. `register` already poisons a replaced connection's outbound queue for
exactly this reason; `deregister` now does the same.
Three changes:
- `deregister` queues the `None` sentinel so the sender loop exits and the
socket tears down, letting the host redial.
- `deregister` takes an optional `conn` generation guard and returns whether it
removed an entry. The tunnel route gates its `set_offline` write on that
return, so a superseded handler reaching cleanup after a reconnect replaced it
can no longer evict the live connection or mark a live host offline.
- `mark_frame_seen` mirrors `TunnelRegistry.mark_frame_seen`: a frame only
refreshes liveness while its connection is current, and the receive loop stops
when it is not.
Six tests added to `tests/server/test_host_registry.py`; five of them fail
against the previous behavior.
Co-authored-by: Isaac
Signed-off-by: Mark Tai <mark.tai@databricks.com>
Co-authored-by: Mark Tai <mark.tai@databricks.com>
* feat(web): fold settled turns behind a 'Worked for Xs' row
Once a turn completes, the chat view collapses its whole process trace
(interstitial narration, tool-run folds, resolved approval cards,
reasoning) behind one muted 'Worked for Xs' expander with a hairline
rule, leaving only the final answer visible - mirroring the Codex
desktop treatment so it's obvious where reading starts instead of a
wall of uniform prose. Expanding the row replays the trace inline.
- Live turns keep their trace expanded; liveness comes from the
bubble's own lifecycle, not session status, so a completed turn
folds even while a later turn streams (and vice versa).
- partitionTurn splits a settled turn into foldable process, exempt
always-visible cards (pending elicitations, persistent
dispatch/routing cards, in-progress spinners), and the trailing
final answer; a turn with no trailing answer (interrupted / failed
/ tool-only) never folds. Resolved approval cards fold with the
trace in document order. Codex's trailing turn_diff bookkeeping
folds as process instead of masquerading as the answer.
- The 'Worked for Xs' duration spans the live stream clock while
streaming, or the items' server created_at stamps on reload;
ConversationItem.to_api_dict() now exposes created_at (additive)
to make the reload path possible.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(stores): expect created_at in the item API-shape round-trip
to_api_dict() now serializes created_at, so the exact-shape assertion
gains the store-assigned stamp.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): demo screenshots + cross-clock note for the turn fold
Adds the collapsed/expanded 'Worked for Xs' screenshots referenced by
the PR description, documents that turnWorkedForS's first block picks
the clock branch, and pins the reverse mixed-clock direction
(live-first, epoch-last) as undefined.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): settle the turn lifecycle on bare terminal status edges
The 'Worked for Xs' fold (and the Fork action) only appeared after
navigating away and back: the turn lifecycle finalized live ONLY on a
session.status edge carrying a matching response id, but most idle
publishes carry none (the PTY-activity relay, orchestration teardown).
So a native turn ending on a bare idle cleared 'Working…' while the
bubble stayed 'streaming' forever — settled state was only re-derived
from the snapshot on reload.
- session_status: any terminal edge (idle/failed/waiting) now
finalizes a still-streaming turn, id-matched or not; cancelled is
preserved. The stray running->idle pair the policy-deny
short-circuit publishes mid-turn is healed by
reviveStrayCompletedResponse: live deltas for the turn flip it back
to streaming, so the misread is a brief flicker, not a mid-turn
fold.
- Mid-turn first open: the initial session bind now reopens the
streaming lifecycle from the snapshot's activeResponseId (mirroring
reconnectStatusPatch), so a running session's live turn renders
expanded instead of prematurely folded.
- e2e: test_bare_idle_finalizes_turn_and_folds drives the exact event
sequence (running+id -> items -> bare idle) against a real server
and asserts the fold forms in place, no reload.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): fold turns split by a sub-agent await, and ease the collapse
Two gaps in the 'Worked for Xs' fold, both visible on a turn that
dispatches sub-agents.
Fold never formed. Dispatching sub-agents ENDS the parent turn — it
must yield to await their results — and the inbox wake starts a new
turn under a new response id carrying the answer. That splits one
logical turn across bubbles: the first holds narration + tool calls
and no answer, the second holds the answer and no work. The fold
required both halves in ONE bubble, so neither qualified and the
narration stayed spread out unfolded. buildBubbles now flags a bubble
whose turn continues in a later assistant bubble (scanning past the
runtime [System: ...] wake markers, stopping at a real user turn),
and such a bubble folds its whole trace despite carrying no answer.
The flag participates in bubblesEqual so the memoized bubble actually
re-renders when its continuation lands.
Collapse was abrupt. The settled render swapped a tall expanded trace
for a one-line row in a single frame, which read as a partial page
reload. The fold now MOUNTS OPEN when the turn settles on screen and
closes on the next frame, so the steps visibly fold into the summary
row; settled history still mounts closed (nothing to animate away).
The height animation lives in index.css because it needs Radix's
measured --radix-collapsible-content-height, and is disabled under
prefers-reduced-motion.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): remove the jolt at the start of the turn-fold collapse
The collapse read as two motions. Measuring the bubble height every
frame through a settle showed why: inserting the summary row and the
fold's own padding/border grew the bubble ~43px TALLER in one frame,
and only then did the 200ms collapse run — a jolt up, then a ramp
down.
- The summary row now grows in (grid-template-rows 0fr -> 1fr) over
the same beat instead of appearing at full height, so row expanding
and trace shrinking net one monotonic shrink.
- The animated element carries no padding or border of its own: any
chrome there is height that lands before the collapse starts, which
is exactly the jolt. Expanded spacing comes from the row's hairline
above and the message column's gap below.
- The fold also animates when it appears on an already-mounted bubble,
not only when the turn itself settles — a turn split by a sub-agent
await folds when its continuation lands, and that case was snapping
shut with no animation at all.
Measured on a live server, same turn shape both times: leading jolt
43px -> 10px, and both the plain and sub-agent-split cases now show a
single animated ramp instead of a jump followed by one.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): stop the turn fold oscillating on codex sessions
On codex the fold flipped collapsed/expanded repeatedly as a turn
streamed. Instrumenting a live turn showed why: the server recorded
that turn as ONE response, but the client showed five to seven
bubbles. A streamed narration renders as its own transient 'live:'
preview bubble until its authoritative item replaces it, and reasoning
bursts group separately, so bubbles appear and merge away on every
delta. Each appearance gave an earlier bubble 'a later assistant
bubble' and marked it continued, folding a fragment; the merge
unmarked it and unfolded it again. Two fragments folded mid-turn as
'Worked for 1s' / 'Worked' rows carrying only a reasoning burst.
- markContinuedTurns only runs between turns: while a response is
streaming the transcript is mid-restructure, so nothing is marked.
Marks are sticky, so a bubble that has folded never reopens when the
next turn starts streaming.
- A continued bubble must also have RUN something (a tool call in its
process) to fold. That is the shape the flag exists for — narration
plus tool calls, then a yield to await sub-agents — and it keeps a
narration- or reasoning-only fragment from folding into a lone
'Worked' row with nothing behind it.
Measured on live codex turns, same prompt shape: fragments folding
mid-turn 2 -> 0, and the only remaining fold is the real one at turn
end.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): never fold a bubble made only of streaming artifacts
Residual codex flicker: the fold still appeared and vanished mid-turn,
just less often. This path never involved the continued flag, which is
why the previous guards only reduced the frequency.
Codex splits an in-flight turn into fragment bubbles — a reasoning
burst (ctx.itemId is null until its item is finalized) plus a 'live:'
narration preview. Their synthetic response id never matches
activeResponse, so walkBubbles labels them 'completed', and a fragment
holding reasoning + text satisfied the ordinary process-plus-answer
rule and folded. When the authoritative item replaced the preview the
fragment merged away and its fold went with it.
A genuine turn always carries at least one server-assigned item id, so
a bubble whose items are ALL null-id or 'live:'-prefixed is a fragment
of the turn still arriving and never folds. LIVE_ITEM_PREFIX moves to
lib/blocks.ts so the renderer and the store share one definition.
Verified by assertion: before this change a reasoning + live-preview
bubble rendered a fold; now it renders expanded. Two frame-exact
recordings of the reported prompt (63k frames, with approvals) showed
no fold disappearing, so this was found by construction rather than by
reproducing it live.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): render a native turn as one bubble live, so it folds like it does on reload
Root cause of the codex fold flicker, found by comparing the same
conversation live vs reloaded: 4 assistant bubbles and NO fold live, 1
bubble folded after a reload. A native turn was being split into
fragment bubbles while streaming and merged back into one on reload,
so the two views disagreed. walkBubbles groups by response id, and
three kinds of block carried the wrong one:
- Live text previews were stamped with a synthetic 'live:<id>' as
their response id, so each streamed narration broke the run. They
now adopt the live turn's id (falling back to the synthetic id when
no turn is tracked, so a preview can't join an unrelated bubble).
- A native harness emits no response.created, so the reducer never
learned the turn id and stamped its own blocks (reasoning, streamed
text) with a stale or empty one. A 'running' status edge carrying a
turn id IS the native turn-start signal, so the reducer adopts it --
without sealing an already-open section, since codex opens reasoning
~2s BEFORE that edge lands and closing would split one thought in
two.
- Blocks emitted in that ~2s window still carry no id, so the store
attributes the trailing unattributed run to the turn when the edge
names it.
With one bubble per turn, the fold condition stops oscillating: it was
flipping because the fragment boundaries moved as previews appeared
and merged, so whichever fragment momentarily had the
process-plus-answer shape folded and then unfolded.
Also: a trailing reasoning item no longer blocks the fold. Codex opens
a reasoning section as the turn ends, landing it after the final
message; reasoning is process, never the answer, so it peels into the
trace like the turn_diff wrap-up already did.
Measured on the reported prompt (with approvals), same shape each
time: bubbles 4 -> 1, and fold transitions went from 'never appears
live' to exactly one 0->1 the instant the turn ends, with zero
decreases (no flicker).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): make bubble grouping and the turn fold robust to a mid-turn connect
The codex flicker survived the response-id stamping fixes because their
premise was fragile: they depend on the client CATCHING the one
'running' status edge that names the turn. A tab that connects (or
reconnects) mid-turn never sees it — SSE replays no status edges — so
reasoning blocks carry rid "" and live previews fall back to their
synthetic ids. Attaching a fresh client to the reporting user's live
session reproduced it exactly: the persisted turn was ONE response, but
the page rendered up to ELEVEN bubbles, five of which folded mid-turn,
including one fold flip back open.
Two structural fixes, replacing edge-dependence with invariants:
- walkBubbles no longer splits a bubble on ANONYMOUS response ids
("" or live:*): such blocks only ever come from the live stream of
the turn around them, so they join it, and a group that OPENED on
anonymous blocks adopts the first real id that arrives. One turn is
now one bubble regardless of which edges the client happened to see.
Bubbles also stop keying off transient live: preview ids, so the
authoritative-item swap no longer remounts the bubble.
- The LAST assistant bubble never folds while the session is running,
even when its lifecycle reads settled — a mid-turn connect misreads
the live turn as 'completed', and folding it collapsed and reopened
the trace as its tail alternated between text and tools. The
session's terminal status edge folds it, which is the natural moment
anyway. Earlier bubbles still fold as usual while a later turn runs.
Verified by attaching mid-turn to a live codex run of the reported
prompt (with approvals): before, 8+ bubbles with 5 mid-turn folds and
a fold flip; after, one bubble, expanded throughout, folding exactly
once when the turn ends.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): one bubble — and one 'Worked for' fold — per user turn
The reported flow (a codex step-wise/goal turn) rendered SEVEN
'Worked for Xs' folds under one user message: codex publishes a
distinct response id per STEP on its status edges while the items all
carry the thread id, so each step opened a new bubble, and every
settled fragment folded separately once the turn ended. The server had
persisted the whole thing as ONE response.
- walkBubbles now groups ONE bubble per user turn: a response-id
change between two assistant blocks with no user message between
them is a continuation (step-wise sub-turns, retries, pre-edge
blocks), not a new turn. The group tracks the LATEST real id so
lifecycle follows the live edge. Blocks stamped a distinct id ON
PURPOSE — deny/failure sentinels and REQUEST-phase elicitations —
still open their own bubble, in both directions.
- Fold appearance is debounced (500ms of held eligibility): a
step-wise turn's between-step idle edge, or a stray idle before its
revive, reads settled for a moment and would otherwise fold and
reopen the trace. Losing eligibility hides the fold immediately, and
settled history still mounts folded with no delay.
Tests that pinned per-response grouping modeled adjacent turns with no
user message between them; real streams separate turns with one (the
inbox wake marker in the sub-agent flow), so they now include it. The
reducer-driven reused-callId test keeps its no-cross-pollination
assertions within the merged bubble.
Verified live: a simulated 5-step turn (distinct per-step edge ids,
one thread id) renders one bubble with zero mid-run folds and exactly
one fold at the end, and a real codex approval run folds once, 0.5s
after the turn ends.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): don't fold a live turn's partial work on a mid-turn refresh
Refreshing while a turn was parked on an approval collapsed the
partial trace into a premature 'Worked for' row. Two holes let the
last-bubble fold suppression miss the live turn on reload:
- The parked elicitation forms its own trailing assistant bubble whose
card ChatPage floats to the page bottom, leaving the bubble
item-less (it renders null) — and that phantom was counted as the
'last assistant' bubble, handing the actual trace to the fold.
lastRenderableAssistantIndex now skips item-less bubbles.
- On a step-wise codex turn the snapshot's active_response_id names
the STEP id while the items carry the thread id, so on reload the
trace's lifecycle reads 'completed' even though the turn is parked.
A pending elicitation now suppresses the last bubble's fold
directly: a card awaiting the user proves the turn is in flight
regardless of what the lifecycle or session status read.
Verified live: reloading a session parked on a codex command approval
keeps the trace expanded with the card visible, and a simulated
mid-turn reload with the step/thread id mismatch stays expanded until
the terminal idle edge, then folds once.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): no 'Worked for' flash when a reload lands between turn steps
Approving an elicitation and refreshing flashed the fold: a step-wise
codex turn publishes an idle edge when each step completes, and a
reload landing in the between-step gap reads fully settled — status
idle, no pending card, trace ending in narration text — so the fold
mounted instantly (the settled-history fast path), then the next
step's running edge cancelled it. Reproduced deterministically: fold
at 0.27s, gone at 1.66s.
Nothing in that snapshot can distinguish the gap from a real turn end,
but the trace's AGE can say how ambiguous it is: items carry server
created_at stamps, so the bubble now records its newest item's time.
The last assistant bubble mounted over a JUST-active trace (newest
item < 15s old) holds its fold for 3s instead of showing it instantly
— long enough for the next step's running edge to cancel it, so the
gap reload never folds at all. A reload after a genuine turn end folds
once the hold elapses, and old history still mounts folded with no
delay.
Verified live against the simulated gap: reload-in-gap shows no fold
ever (was flash-then-hide), reload-after-real-end folds at ~3s, and
stale-history mounts fold instantly (unit-tested).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): don't pop a settled turn's fold open while the next turn spins up
Once a real user message follows the last assistant bubble, a running
status belongs to the reply-in-flight for that newer input, so the
settled bubble's 'Worked for' fold must not be suppressed. Closes the
opencode dip where the prior fold opened for seconds until the new
turn's first item mirrored through the TUI.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(web): scroll the expanded 'Worked for' trace into view
Clicking the fold expands the trace above the reading position, and the
browser's scroll anchoring keeps the answer below it stationary — the
work opens off the top of the viewport and the click looks like a no-op.
On a user-initiated expand whose row+trace don't fit the scroller, snap
the fold row to the top (before paint) so the trace reads from its
beginning. Fits-on-screen expands and the programmatic mount-collapse
animation don't scroll.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e): cover the 'Worked for' fold across native wire shapes
Four deterministic events-API tests: step-wise per-step status edges
fold once with no mid-run flicker; items that switch response id
mid-turn still yield one fold per user message; a mid-turn reload keeps
partial work expanded until the terminal edge; and a settled turn's
fold holds through a follow-up send's item-less gap.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): always snap the fold row on user expand
The fits-on-screen fast path never held in practice: on the last turn
the stick-to-bottom scroller treats the 200ms expand animation as
appended content and re-pins the bottom, and elsewhere native scroll
anchoring pins the answer below — either way the growing trace glides
the row off the top and the click looks like a no-op. Snap the row to
the scroller top on every user expand (the upward scroll also unpins
stick-to-bottom) and park overflow-anchor for the animation.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): make the fold's expand snap win against the bottom-lock
Clicking 'Worked for' while the view is pinned at the bottom (the
resting position on the last turn) did nothing: the expand animation
opens at height 0, so the snap clamps against a scroller with no room,
and stick-to-bottom's resize handler then rides the growth to the
bottom — programmatic scrolls never unpin it. User expands now open at
full height in one frame (no height animation), release the bottom-lock
via a null-safe ConversationScrollLockContext (same recipe as
JumpToTopButton), and then snap the row to the top.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): land the fold's snap below the chat top fade
The snap parked the row 8px below the scroller edge — inside
chat-scroll-fade's transparent band (opaque only from 80px), so the
'Worked for' label sat scrolled-to-top yet invisible. The row's
scroll-margin-top now lives next to the fade definition (88px, plus the
iOS inset variant) so the two can't desync.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): restore the session busy signal when a live delta revives a turn
A stray idle edge clears sessionStatus before the revive flips the
turn back to streaming, so shouldQueueSend saw an idle session and let
a mid-turn send bypass the queue (and the Working indicator stayed dark
until the next running edge). The delta that triggers the revive proves
the session is mid-turn — restore sessionStatus: 'running' with it.
Local send status stays untouched: cross-client and TUI-typed turns
have no local send in flight.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): drop the turn-fold demo screenshots from the repo
The PR description references them by pinned commit SHA, so the binary
assets don't need to live in the tree.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
## Related issue
N/A
## Summary
- The Databricks AI gateway only serves Claude requests in coding-agent mode when the `x-databricks-use-coding-agent-mode: true` request header is present; omnigent's Claude launches to the gateway did not send it.
- `ClaudeSDKExecutor`'s Databricks gateway env (`_resolve_gateway_env`) and native-claude's ucode launch config now pass `ANTHROPIC_CUSTOM_HEADERS=x-databricks-use-coding-agent-mode: true`, which Claude Code forwards verbatim as request headers (this survives the thinking-display gateway shim, which forwards all request headers).
- Generic-provider gateway envs (non-Databricks `key`/`gateway` providers) deliberately do not receive the header.
## Test Plan
- `uv run pytest tests/test_claude_native.py tests/inner/test_claude_sdk_executor.py -q` — 283 passed.
- Updated the ucode env exact-equality assertion and gateway-env tests to assert the header; added `test_generic_provider_gateway_omits_databricks_header` to pin the Databricks-only scoping.
- `uv run ruff check` and `uv run ruff format --check` on the touched files — clean.
## 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
N/A
## Changelog
Claude sessions routed through the Databricks AI gateway now send the `x-databricks-use-coding-agent-mode` header the gateway requires
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
Moves aravind-segu from `owners` to `owners_paused` in the 12 areas they
owned, so PR reviewer assignment and issue triage stop routing to them.
Readers use only `owners`; the 2+ owner check counts paused owners, so no
backfill was needed and no area is left without an active owner.
`policies` is now down to a single active owner (TomeHirata).
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(codex-native): clear the MCP startup band once the model starts working
The web chat showed 'Starting MCP servers (3/4): <name>' underneath an
agent that was visibly already working, sometimes for minutes.
Codex delivers per-server startup edges only to the connection that owns
the thread, so the forwarder synthesizes the round and settles it when
the thread goes idle after a turn, or when a config-derived window
elapses. Both are late: a server that never reaches a terminal state
(e.g. a misconfigured command that never handshakes) keeps the band
pinned for the whole first turn, and the window stretches to the slowest
configured startup_timeout_sec plus grace (135s for a 120s budget).
Settle on the first model-produced turn item as well. Codex defers turn
EXECUTION until the startup round ends, so assistant-side output proves
the round is over while the turn is still running - the same invariant
the idle-edge settle already relies on, observed at the earliest point
it can be. The band now covers only the genuine pre-turn wait.
The turn's userMessage item is excluded, and only parent-thread events
count: a turn is ACCEPTED (thread flips active, user message
materializes) mid-startup, and a collab child's turn says nothing about
the parent's round.
Two adjacent fixes fall out: a mid-turn reload no longer re-shows the
stale band from the session snapshot, and hitting Stop during a first
turn no longer reports 'cancelled' for servers whose startup had in fact
finished. The failed-turn diagnostic that names still-pending servers is
unaffected - a failed turn/start produces no model output, so no settle
precedes it.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(codex-native): settle the MCP round once, and add before/after visuals
Addresses review feedback on the settle-on-model-output change:
- Settle at most once per forwarder connection. The round is seeded
once per connection and never on thread rotation, so once model
output settles it the outcome cannot change; without a guard every
later item in the session re-read the bridge file to reach the same
idempotent no-op. A state flag short-circuits them, and the new test
re-populates the map behind the flag so dropping the guard fails
rather than passing on idempotency alone.
- Add the before/after chat captures the review asked for, taken at the
same point in the turn (agent running 'sleep 40') against servers
built from the same web UI, differing only in this fix.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore(codex-native): drop the committed demo screenshots
The before/after captures don't need to live in the repo; the same
evidence is in the PR description as the sampled A/B table and the
runner-log timeline.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(cursor-native): merge Omnigent MCP bridge into existing mcp.json instead of overwriting
write_mcp_config() previously called build_mcp_config() which returned a
dict with only the Omnigent bridge MCP server, then wrote it wholesale to
.cursor/mcp.json. This destroyed any user-configured MCP servers.
Now read the existing mcp.json, merge the Omnigent entry into mcpServers
leaving other keys intact, and write back the merged config.
Fixes#3083
Signed-off-by: lg320531124 <155300404+lg320531124@users.noreply.github.com>
* fix(cursor-native): guard malformed mcp.json and cover the merge path
A hand-edited .cursor/mcp.json can hold any JSON shape. The merge read it
and indexed straight into it, so a list/null root or a non-dict mcpServers
raised AttributeError/TypeError and took down the session launch, where the
old overwrite-always code could not.
Discard non-dict shapes before merging, swap the try/except/pass for
contextlib.suppress (SIM105), and add tests for the merge path (user server
plus a sibling top-level key survive) and the malformed shapes.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(cursor-native): type the mcp.json merge against JsonObject
main moved this module off typing.Any onto JsonObject (dict[str, object]),
so the merge's `dict[str, Any]` annotation broke ruff F821 and pyrefly
once rebased, and indexing the object-valued mcpServers failed bad-index.
Narrow the loaded JSON with isinstance into a local `servers` dict (the
pattern opencode_native_provider already uses) and bind it back into
`existing`, so the write lands through the alias and stays typed.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: lg320531124 <155300404+lg320531124@users.noreply.github.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The example Postgres URLs in these docstrings are placeholders, but gitleaks'
`postgres-connection-string` rule matches the `scheme://name:secret@host` shape
and can't tell a placeholder from a live DSN. That makes them permanent false
positives: they show up in GitGuardian digests, and the Databricks pre-push hook
re-flags them on every new-branch push, since pushing a new branch re-scans
commits already on main. Working around that means reaching for
SKIP_SECRET_SCAN, which is a habit worth not having.
Switching the examples to angle-bracket placeholders sidesteps the rule (`<` and
`>` fall outside its username/password character classes), and reads more
clearly as a placeholder besides.
Docstrings and comments only: with docstrings stripped, the AST of every touched
file is byte-identical to before. Test files are deliberately left alone: their
URLs are live inputs and expected values, and one case exists specifically to
prove percent-encoded credentials survive the prefix rewrite, so rewriting it
would defeat the test. Those remaining findings are best marked as false
positives in the scanner instead.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The private Databricks secure-release repo was named in 9 places: three
workflow header comments, the `release.yml` run summary, a design-doc table
row, and four direct links into the private repo's file tree from
`editors/vscode/PUBLISHING.md`. None of it resolves for anyone outside
Databricks.
`release.yml` printed the name into its run summary on every release. Public
run summaries are world-readable, so a repo variable would keep leaking it.
The summary now prints the full command with `<secure-release-repo>` as the
only placeholder, so a release manager still gets something to paste and fill
in, and points at the runbook for the value.
The rest is a straight substitution to "a Databricks-internal secure-release
repo". `PUBLISHING.md` keeps the build half and defers the repo name and
workflow paths to the runbook.
No behaviour change: no trigger, input, permission, or step logic is touched.
The only executable change is the summary `echo` block, verified by extracting
it from the YAML and running it.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
`RELEASING.md` documents the whole release pipeline, including the private
Databricks secure-release repo, its workflow filenames, and its dispatch
inputs. A public reader can't act on any of that, so per the thread with Corey
and Rice it moves to `omnigent-internal` (`RELEASING.md`).
This deletes the file here and repoints the six inbound "see RELEASING.md"
pointers (4 workflows, the changelog script) at "the maintainer release
runbook", so nothing links to a path that no longer exists.
Scrubbing the private repo name from the workflow comments and
`editors/vscode/PUBLISHING.md` is a separate follow-up.
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(sessions): expose persisted activity heartbeat
Signed-off-by: Solaris-star <820622658@qq.com>
* docs: broaden updated_at wording to cover session metadata edits
Per review feedback: updated_at also advances on title renames
(including auto-titling), agent switches, and archive toggles — not
just conversation item appends. An orchestrator treating it as a pure
item-append heartbeat should know a mid-stall rename resets the clock.
Broadened the docstring in SessionResponse and the SDK Session class,
and re-ran scripts/dump_openapi.py so the OpenAPI description matches.
---------
Signed-off-by: Solaris-star <820622658@qq.com>
Two bugs in the Kimi Code (kimi) harness integration:
Bug 1 - Omnigent could never detect a completed kimi login. The KIMI_KEY
install spec had no file-based login detector and the setup overview row was
hardcoded to "Not configured"/warn whenever the CLI was installed, so a
successful `kimi login` always showed as not signed in.
Fix: add a subprocess-free detector `kimi_auth.kimi_login_detected()` that
returns True when `~/.kimi-code/credentials/kimi-code.json` exists and is
non-empty (the file `kimi login` writes; verified against kimi CLI v0.29.1),
mirroring the Gemini `gemini_login_detected()` pattern. Wire it into
`harness_readiness._FAMILY_CREDENTIAL_CHECK` (binary + credential gating, like
agy) and make the setup overview row render green "Signed in" when detected.
Bug 2 - Sign-out was broken. The spec declared `logout_args=("logout",)` but
kimi has no `logout` subcommand (`kimi logout` errors "unknown command" on
v0.29.1). Set `logout_args=None` so `harness_logout` is a no-op for kimi (same
as Qwen / agy) and remove the "Sign out (kimi logout)" row and its branch from
the Kimi drill-in. Docstrings/comments claiming kimi ships `kimi logout` are
corrected.
Tests: add tests/onboarding/test_kimi_auth.py (present/absent/empty credential
via tmp paths), update the harness_install/harness_readiness onboarding tests
for the new logout_args=None and binary+credential readiness, and update the
CLI drill-in / setup-overview tests (no sign-out row; signed-in vs
not-configured overview row).
Signed-off-by: evangoh122 <evangohsg@gmail.com>
Signed-off-by: Evan Goh <authoremail@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Approving a plan from the web UI did nothing: the card showed as
approved but the plan never ran, and answering in the terminal view was
the only way through. Claude Code ignores a PermissionRequest hook's
`allow` for ExitPlanMode (that dialog only accepts a TUI answer), so the
`setMode` decision the server builds never took effect. As a result
Claude's `auto` mode was unreachable from the web UI, since the plan
card is the only surface that offers it.
Key the verdict into the pane instead, the way a local user would:
option 1 for accept-with-auto-mode, 2 for accept, Escape for reject.
The bridge only presses a key when the plan dialog is actually on
screen, which keeps a non-plan verdict (or one already answered in the
terminal) a no-op. Rides the approval event the server already forwards
to the runner, so no new event type or server plumbing.
Co-authored-by: Isaac
Signed-off-by: Pranav Setlur <psetlur@gmail.com>
The Databricks Apps entrypoint built every other store but never the
project store, and create_app mounts the projects router only when a
project store is wired — so first-class Projects were non-functional
on every Databricks Apps deployment while the bundled web UI still
offered project creation. The CLI server and Docker entrypoint paths
already wire it.
Construct SqlAlchemyProjectStore from the Lakebase DB URI and pass it
to create_app, mirroring the other stores.
Co-authored-by: Isaac
Claude-Session: https://claude.ai/code/session_01P9dr2dYHrwMvnXvJsjLDKk
Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
* Central CTA + background bugfix
Landing screen:
- Headline moves to Hanken Grotesk at 400 weight ("What should we build?"),
self-hosted via @fontsource-variable so no CDN is involved, exposed as the
`font-display-alt` token.
- The project variant swaps the bare folder glyph for a pink rounded tile,
using a new `tag-pink` token from the design's tag palette.
- The composer placeholder and its aria-label now name the selected project
("Start a new session in <project>") instead of always reading the generic
task prompt.
Bug fix — the mobile sidebar was see-through. Below md the sidebar is a
full-screen overlay on top of the chat, but the per-theme canvas rules paint
it with the `background` shorthand, which resets background-color and silently
overrode Sidebar.tsx's max-md:bg-card-solid; the dark stack is entirely
translucent, so the conversation showed straight through. Restores an opaque
fill under the gradients below md only, at matching specificity and after the
theme rules, so desktop keeps its intended translucency.
Adds regression tests for that contract, and updates the landing-screen tests
and visual-suite docs for the new headline.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
* test(e2e-ui): regenerate visual baselines
---------
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* perf(runner): bound per-runner memory via glibc arenas + threadpool cap
Each session spawns its own runner process, and each grows to ~200MB in
prod, over-using host resources. Profiling shows ~123MB is the irreducible
import floor; the growth on top is runtime bloat from threaded Python on
glibc: the runner offloads heavily via asyncio.to_thread, the default
executor sizes to min(32, cpu+4) threads, and glibc opens up to 8*ncpu
malloc arenas that never return to the OS. Nothing tuned any of this.
Three low-risk, env-gated levers (all no-ops or benign off Linux):
- MALLOC_ARENA_MAX=2 + a 128 MiB trim threshold, injected into the runner
child env at both spawn sites via a shared _proc.malloc_tuning_env()
helper. Empty off Linux; OMNIGENT_RUNNER_MALLOC_ARENA_MAX=0 reverts.
- Cap the asyncio default executor at 8 workers (runner.threadpool_max_workers
config key, OMNIGENT_RUNNER_THREADPOOL_MAX_WORKERS env override), set before
any to_thread use so the 20-thread default pool is never created.
- gc.freeze() after app construction to drop the static import graph from
GC's tracked set.
This targets the runtime growth, not the import floor; collapsing the floor
itself (a copy-on-write zygote) is tracked separately.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): apply the glibc arena cap at the zygote exec
The zygote forkserver landed and is now the default runner spawn path, which
silently defeated this branch's MALLOC_ARENA_MAX injection. glibc reads that
variable once, when its allocator initializes at exec; a zygote-forked runner
never execs, it just replaces os.environ, so the value arrived far too late to
configure an allocator and the cap stopped applying to every runner.
Move the injection to the zygote's own Popen -- the single real exec on this
path -- so all forked runners and harnesses inherit an already-capped
allocator. Two tests pin the contract at that boundary, including that an
operator's explicit export still wins.
The other two levers on this branch (the 8-worker threadpool cap and
gc.freeze()) live inside _run_tunnel_from_env, which every runner reaches
regardless of how it was started, so they were unaffected. Note in
malloc_tuning_env why the arena cap is glibc-only: macOS libmalloc uses
per-CPU magazines with madvise reclaim and ignores MALLOC_ARENA_MAX, so macOS
hosts get their reduction from the threadpool cap (measured: 21 threads -> 9).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
nimble-python is the optional `nimble` extra and the import is already
guarded by try/except ImportError. Pyrefly has no way to know it's
intentionally absent, so annotate with `# pyrefly: ignore[missing-import]`
to silence the false-positive without changing runtime behaviour.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(policies): add force-push protection to GitHub policy
Add a `deny_force_push` parameter (default `True`) to the GitHub
policy that blocks `git push` with force flags (`--force`, `-f`,
`--force-with-lease`, `--force-if-includes`) regardless of
repo/branch allowlists. This prevents agents from rewriting remote
history, which can destroy commits and break collaborators' clones.
The check fires before repo/branch gating so even a force push to
an undeterminable remote alias is denied rather than surfaced as ASK.
Set `deny_force_push=False` to let force pushes through normal
write gating.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(policies): merge startswith calls to satisfy ruff PIE810
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* style(policies): join force-push condition onto one line for ruff format
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* spike(runner): measure copy-on-write savings from a warm-fork zygote
Each session spawns its own runner process, and each pays a ~123MB import
floor for omnigent's own graph plus pydantic/fastapi/httpx. Runtime tuning
trims the growth on top but can't touch that floor; the only way to collapse
it is to import the graph once in a warm parent and os.fork() a child per
session, sharing the read-only import pages copy-on-write.
This standalone script measures whether that COW sharing actually
materializes before we commit to the full zygote architecture. It imports the
runner graph once, forks N idle children, and reports aggregate memory against
an N-process Popen baseline, optionally with gc.freeze().
Not wired into the daemon — this is a measurement gate, not a feature. On this
macOS box (N=8) the fork path showed ~82% lower aggregate footprint than the
Popen baseline, but macOS phys_footprint is only an indicative analog to Linux
Pss and the children idle (no COW erosion from refcount page-dirtying), so a
Linux-under-load measurement is still required before productionizing.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(runner): add copy-on-write zygote forkserver for runner processes
Every session spawns its own runner, and each pays the full ~120MB import
floor (omnigent's graph + pydantic/fastapi/httpx). On a host running N
sessions that floor is duplicated N times. This adds a zygote: a single
long-lived process that imports the runner graph once and os.fork()s a child
per session, so on Linux the read-only import pages are shared copy-on-write
and each extra runner costs only the pages it dirties.
Design (grounded in the daemon/runner lifecycle, not the naive sketch):
- omnigent/runner/_zygote.py — the forkserver. Single-threaded, no event loop
or network; imports the graph once, gc.freeze()s it, then blocks on an
AF_UNIX control socket forking a child per request. The child reopens its
log, replaces os.environ with the request env, and calls the unchanged
_entry.main() — so it behaves exactly like `python -m omnigent.runner._entry`.
It is Popen-exec'd by the daemon (never forked from it), so it inherits none
of the daemon's asyncio loop / websocket / worker threads — the classic
fork-in-multithreaded-async deadlock is avoided by construction.
- omnigent/host/runner_zygote.py — the daemon-side client. ZygoteManager owns
the control socket; ZygoteRunnerProc is a Popen-shaped shim so the existing
_RunnerHandle / _watch_runner / _handle_stop paths are unchanged. The daemon
is NOT the forked runner's parent, so poll()/returncode/wait() round-trip to
the zygote (the real parent) for exit status while terminate()/kill() signal
the pid directly.
- connect.py — _handle_launch forks via the zygote when enabled, else the
original Popen. RUNNER_PARENT_PID is set to the ZYGOTE's pid (not the
daemon's) because the runner's orphan watchdog compares os.getppid(); daemon
death -> control-socket EOF -> zygote exit -> runners reparent -> each tears
itself down, preserving today's parent-death semantics through one hop.
Gated behind OMNIGENT_RUNNER_ZYGOTE=1 and Linux-only; any zygote failure
disables it for the daemon's life and falls back to a direct Popen, so it is
never a hard dependency. Also removes the Phase-1 measurement spike script,
which this supersedes.
Verified on macOS: a real zygote subprocess forks children, reports pids and
exit codes, isolates per-fork env, reaps cleanly, and tears down on stop
(fork works on macOS even though the COW savings are Linux-only). The
production memory win and a full session-through-the-tunnel run are unverified
here — they need a Linux host under load, which this change is written to be
turned on for.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): address zygote review feedback
- connect.py: a failed fork no longer stops the running zygote. Stopping it
would kill healthy runners already forked from it (their orphan watchdog
sees the parent die), so one bad fork could take down unrelated live
sessions. Latch a `_zygote_disabled` flag for future launches instead and
retain the manager so the zygote is still reaped on daemon shutdown.
- runner_zygote.py: wait() after kill() in stop() so a zygote that ignored
SIGTERM is reaped rather than lingering as a zombie.
- _zygote.py: unify the _entry/app/native import to a single `from ... import`
(CodeQL flagged mixed import styles).
- test: build the fresh-interpreter probe via an explicit newline join instead
of implicit adjacent-string concatenation (CodeQL).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): forward --log-to-stderr TTY fd through the zygote
The direct-Popen launch path forwards OMNIGENT_LOG_TTY_FD via
child_logging_popen_kwargs so a detached runner can still mirror logs to the
daemon's terminal. The zygote path dropped it, so --log-to-stderr mirroring
was lost for zygote-forked runners.
Forward it across both hops:
- daemon -> zygote: reuse child_logging_popen_kwargs to dup the TTY fd and add
it to the zygote's pass_fds (the helper also rewrites env[LOG_TTY_FD] to the
duped number).
- zygote -> forked runner: the valid fd number inside the child is the one the
zygote inherited, not the daemon-side number the payload carries, so the
child restores LOG_TTY_FD from the zygote's own value (and clears a stale
payload value when the zygote has no terminal mirror).
Adds a test asserting a bogus payload LOG_TTY_FD is cleared in the child.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): address second round of zygote review feedback
- _zygote.py: create the forked child's log file 0o600, not 0o644. Runner
logs can carry secrets (tokens, prompts); matches create_process_log_path.
- _zygote.py: the child guard now preserves SystemExit's code instead of
flattening it to a traceback + exit 1, so a zygote-forked runner exits with
the same code as `python -m omnigent.runner._entry` (main() raises
SystemExit on a tunnel rejection). New test covers it via a raise seam.
- runner_zygote.py: stop the partially-started zygote if the initial ping
raises (timeout / EOF), so a failed start never leaks a process + socket.
- runner_zygote.py: signal via signal.SIGTERM / signal.SIGKILL instead of the
raw 15 / 9.
- test: mark the suite posix_only (it uses os.fork / pass_fds) so cross-
platform sweeps skip it on Windows.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(runner): enable the zygote on all POSIX hosts, not just Linux
The host daemon runs on the user's own machine — most often macOS — so a
Linux-only gate denied the copy-on-write import-floor savings to the majority
of hosts. Gate on IS_POSIX instead (the zygote needs os.fork + AF_UNIX
fd-passing, both POSIX; Windows still takes the direct Popen path).
macOS is the platform where fork-without-exec is riskiest (CoreFoundation/GCD
abort a forked child that touches them), so this was verified rather than
assumed. The abort is triggered by forking from a MULTI-threaded process, which
the zygote already designs against: it forks from a single-threaded parent
(asserted active_count()==1) and does create_app + all network work in the
child. Evidence on this macOS box:
- A faithful fork probe (fork from the single-threaded import state, child runs
create_app + getaddrinfo + TLS ctx + asyncio + httpx) survived 5/5. The same
work forked from a multi-threaded parent SIGSEGV'd 2/3 — confirming the
single-threaded fork is what makes it safe.
- test_host_launch_runner_and_session_round_trip passes with
OMNIGENT_RUNNER_ZYGOTE=1: a real host daemon forks a runner through the
zygote, the runner connects its tunnel, and a full mock-LLM session round-trip
completes. The daemon log confirms the zygote path (distinct zygote/runner
pids), not a Popen fallback.
Also adds an info log on the successful zygote-fork path so operators can see
the zygote is active and which pids are involved.
Still opt-in behind OMNIGENT_RUNNER_ZYGOTE=1 with the full Popen fallback; the
steady-state Pss win under load remains best measured on a Linux host.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(runner): fork harness subprocesses from the runner zygote
The harness subprocess (`python -m omnigent.runtime.harnesses._runner`) is a
separate exec per conversation, so it re-pays its import floor — and that floor
is ~54MB of the same common graph (fastapi/pydantic/omnigent-core) the runner
zygote already holds resident. This extends the zygote to fork harness children
too, sharing that graph copy-on-write instead of exec'ing a fresh interpreter.
- _zygote.py: the serve loop becomes a single-threaded `selectors` multiplexer
over the daemon socket PLUS one inherited control socket per forked runner.
A new `fork_harness` command forks a child that reproduces `_runner.main(argv)`
in-process. The runner-fork request/response bytes are unchanged; the new
multiplexer wraps them rather than rewriting them. A forked child closes every
inherited zygote-side socket (it never speaks the fork protocol).
- _harness_zygote_client.py (new): the runner-side client. `HarnessZygoteClient`
reads the inherited control-socket fd from OMNIGENT_RUNNER_ZYGOTE_HARNESS_FD;
`ZygoteHarnessProc` is an asyncio.subprocess.Process-shaped shim (pid /
returncode / wait / send_signal / kill) with a background poll task keeping
returncode fresh for _wait_for_bind's synchronous reads.
- process_manager.py: `_spawn_harness_process` forks via the zygote when the
runner was itself zygote-forked, else the original create_subprocess_exec;
disabled on first failure so it falls back for the process's life.
- _runner.py: a zygote-forked harness has the zygote (not the runner) as OS
parent, so its watchdog probes the runner pid explicitly instead of trusting
os.getppid(), and skips PR_SET_PDEATHSIG (which would bind death to the
zygote). Gated by OMNIGENT_HARNESS_ZYGOTE_FORKED.
Present only when the runner itself was zygote-forked; any failure falls back to
a direct exec, so the harness fork is never a hard dependency. The win is
bounded to the ~54MB Python wrapper (the external claude/codex CLI is a separate
exec no Python zygote can share) and materializes under multi-conversation
fan-out. Verified on macOS: fork_harness forks, reports pid + exit code,
round-trips argv, reaps, and leaves the daemon socket serving; existing
process_manager tests unchanged. Linux Pss savings still unverified.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): clear pyrefly type errors in the zygote
- ZygoteRunnerProc.wait: narrow on `timeout` (not just `deadline`) so
TimeoutExpired(timeout=...) gets a `float`, not `float | None`.
- _spawn_zygote_process: pass stdin/stdout/stderr explicitly with a typed
`BinaryIO | None` log handle instead of a `dict[str, object]` splat that
matched no Popen overload.
- _ZygoteServer.serve: cast selector key.fileobj (HasFileno | int) to socket
— only sockets are ever registered.
- _ZygoteServer._on_readable: wrap the bytearray partition result in bytes()
before dispatch, which expects bytes.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): harden zygote failure paths (crash recovery, exit-code leak)
Review flagged three correctness bugs in the unhappy lifecycle paths; none are
security issues but each is reachable in prod.
1. Unexpected zygote crash stranded the daemon's view of every child. The
daemon isn't the runner's OS parent, so once the zygote died it had no
channel to learn a runner exited — ZygoteManager.poll returned None
("still live") forever, so _watch_runner looped, _handle_runner_status
reported gone sessions as alive, and _handle_stop's final wait() could hang.
Now poll() probes the runner pid directly when the zygote is gone: a dead
pid surfaces a non-zero sentinel (254) so the runner reads as dead-and-
failed, not eternal alive. _handle_stop's post-kill wait() is now bounded.
2. _exit_codes leaked for a dropped runner's harness children. Exit codes were
only popped via poll, but a dropped runner's harnesses have no remaining
client to poll them — the entries accumulated (unbounded map growth +
pid-reuse misattribution). _drop_runner now discards those descendants'
codes and marks still-live ones orphaned: _reap waitpid's them (no zombies)
but discards the code instead of storing it.
3. ZygoteHarnessProc.wait() masked a crashed harness as exit 0. If the zygote
went away, wait() returned 0, so a harness that crashed on boot (bind
failure, import error) read as a clean exit and the process manager could
hang waiting for a bind that never comes. Now probes the harness pid and
returns a non-zero sentinel when the code is unrecoverable.
Also: tighten "Linux-only" docstrings to "POSIX; COW savings on Linux" (the
gate is IS_POSIX and the path runs on macOS), and add a sleep test-seam so the
new failure-path tests can hold a child genuinely alive.
Tests: kill the zygote under a live runner and assert the daemon eventually
sees it dead (not hanging); a dropped runner's harness code is not retained; a
crashed harness with an unrecoverable code surfaces as failure, not 0.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): keep zygote poll/wait off the daemon event loop
Review flagged a liveness regression on the enabled path: for a zygote-forked
runner, poll()/wait() are blocking control-socket round-trips (with lock
contention against a booting zygote that holds the lock across its ~120MB
import), not the lock-free waitpid the direct-Popen path used. Calling them on
the loop thread could freeze the whole daemon — all sessions, websocket
traffic, heartbeats — until the import finishes or the 30s control timeout
elapses.
- _watch_runner: poll() now runs via asyncio.to_thread.
- _handle_stop: now async; the poll/terminate/wait sequence runs off-loop in a
_stop_runner_proc helper. Its dispatch site and three tests updated to await.
- _tracked_runner_pids: include the zygote pid so the orphan reaper never
waitpid's the zygote out from under ZygoteManager._proc on an unexpected
crash (which would confuse is_running()/stop()).
Also updates test_poll_after_stop to use a live child, since the crash-recovery
sentinel (254) now correctly fires for an already-exited pid after stop().
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): status query off-loop + enable zygote by default
- _handle_runner_status did its poll() on the event loop, the one place the
PR hadn't moved off it. For a zygote-forked runner poll() is a blocking
control-socket round-trip (bounded only by the 30s control timeout, and
contended against a booting zygote), so a slow zygote could stall the whole
daemon for a single status query. Made it async and run the poll via
asyncio.to_thread, matching _watch_runner / _handle_stop. Dispatch site and
the three status tests updated to await.
- Enable the zygote by default: OMNIGENT_RUNNER_ZYGOTE is now opt-OUT
(=0/false/no/off), not opt-in. The host daemon runs on the user's own
machine (most often macOS), so defaulting on lets most users share the
~120MB import floor. Still POSIX-gated with a full Popen fallback, so an
unsupported platform or any zygote failure is transparent.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: prevent mid-spawn launch leaks and harden zygote request handling
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(web): set the text size steps from the design
Body and chat-thread text are both 13px/18px in the design; the shared
`text-13` step was on a 20px line, so tighten it to 18px. Adds the 12px/16px
caption step used by sidebar section subtitles (Projects, Sessions).
Defines the steps only — switching each surface onto them is follow-up work.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
* feat(web): put chat and sidebar text on the design's type scale
The chat thread hard-coded its own 15px/24px with negative tracking, and
sidebar rows set a size but no line height, so neither matched the design.
- Chat bubbles (user and assistant share the wrapper): 13px/18px, and the
-0.01em tracking is dropped — the design specifies 0.
- Sidebar body rows: pin the line height to 18/13 of the font size, which was
previously left to inherit.
Both stay in rem/unitless so the mobile root-font bump and the Appearance
font-size setting keep scaling them. Sidebar section captions were already
12px/16px and are unchanged.
Co-authored-by: Isaac
* test(e2e-ui): regenerate visual baselines
* refactor(web): express the sidebar line height in rem
1.3846 was the 18/13 ratio written as a unitless number — unreadable, and it
took arithmetic to confirm it meant 18px. 1.125rem is 18px directly and
scales the same way, matching how the chat wrapper states it.
Co-authored-by: Isaac
---------
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
OMNIGENT_RUNNER_ENV_PASSTHROUGH lets an operator name extra env vars for the
host to forward on to spawned runners (provider gateway wiring, config env: refs,
etc.). It worked locally but was a silent no-op in --server mode: the remote
daemon env is allowlisted by a prefix set of DATABRICKS_ + LC_/MLFLOW_/OTEL_/
OMNIGENT_OTEL_ — NOT plain OMNIGENT_ — so the control var itself was stripped at
the CLI->daemon hop, and _build_runner_env never saw the names it listed. Any var
forwarded through the passthrough (e.g. a Linear API key for the repro-agent)
reached the runner locally but never remotely.
Add OMNIGENT_RUNNER_ENV_PASSTHROUGH to _RUNNER_ENV_ALLOWLIST so it survives both
hops. It carries only env var NAMES, not secrets, so allowlisting it leaks
nothing on its own — each named var must still independently reach the daemon
(here via the DATABRICKS_ prefix).
Tests: a daemon-hop test (remote env keeps the control var) and an end-to-end
two-hop test (a named var survives CLI->daemon->runner, an unnamed one doesn't).
Both fail without the one-line allowlist change.
Co-authored-by: Isaac
* dev/repro-agent: give Linear tickets a real fetch path + follow linked GitHub issues
The local repro-agent pointed Linear tickets at nonexistent "Linear tools",
so Linear runs had no way to read the ticket body and fell back to guessing
from the URL slug — noticeably worse reproductions than GitHub issues, which
have a working `gh issue view` path.
Wire Linear to the same GraphQL path the internal issue-sync agent uses
(api.linear.app/graphql, `Authorization: $LINEAR_API_KEY`, no Bearer), pulling
description/comments/attachments via sys_os_shell. When the key is absent or
auth fails, stop with needs_more_info naming the missing key instead of
guessing. Also: when a Linear ticket links a GitHub issue, always fetch that
issue too and treat it as authoritative for the technical journey — that
richer thread is why GitHub-first runs reproduced better.
Co-authored-by: Isaac
* dev/repro: forward the Linear key through the --server env strip
Reading a Linear ticket needs the key in the agent's shell, but under --server
the CLI->daemon->runner hops strip everything not allowlisted. The DATABRICKS_
prefix survives only the first hop; the daemon->runner hop has no DATABRICKS_
prefix. So dev/repro.py now names DATABRICKS_LINEAR_API_KEY in
OMNIGENT_RUNNER_ENV_PASSTHROUGH (itself allowlisted) when a Linear URL is passed
and the key is set, which forwards it the rest of the way. AGENTS.md reads
whichever name is present (LINEAR_API_KEY locally, DATABRICKS_LINEAR_API_KEY
under --server). Warns rather than fails when the key is missing.
Companion change (omnigent-internal): the repro-agent CI workflow must set
DATABRICKS_LINEAR_API_KEY from secrets.LINEAR_API_KEY in the run step, mirroring
how it already sets DATABRICKS_BEARER for the LLM key.
Co-authored-by: Isaac
* dev/repro: mirror LINEAR_API_KEY into the DATABRICKS_ name
Maintainers typically export the plain LINEAR_API_KEY locally, so copy it into
DATABRICKS_LINEAR_API_KEY when only the plain name is set — then the same
passthrough forwarding carries it past the --server env strip. Warn only when
neither is set.
Co-authored-by: Isaac
* feat(web): make the rails flush boxes and move the canvas gradient
The sidebar and workspace rails were floating cards (margin, rounded
corners, border, shadow) on a gradient canvas. The design has them flush to
the window edges, reading as part of the canvas.
- Left sidebar and right workspace rail sit flush: no outer margin, no
rounding, no drop shadow. The workspace rail keeps a left divider.
- Light canvas is flat white; the brand gradient moves onto the left
sidebar, joined by the mock's dot-grid and pink corner glow.
- Dark canvas carries the mock's purple gradient; the dark sidebar gets the
same dot-grid plus a purple bottom wash and the diagonal sheen.
- Both rails are excluded from the dark glass rule instead of overriding it,
so they no longer pick up its blur, sheen, fill, or border. The workspace
rail's panel contents are transparent too.
- Dark surface tokens (--card, --card-solid, --tray, --muted, --background)
move off their purple tint onto neutral slate.
Consolidates the canvas/rail CSS so each surface owns its full background in
one rule, and drops the now-redundant ::before dot overlay.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
* test(e2e-ui): regenerate visual baselines
---------
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
Broaden issue-triage auto-assignment from P0/P1-only to every triaged
issue except needs_info ones. The gate now keys off needs_info alone, so
any bug/enhancement/doc issue with enough info to triage gets a
load-balanced area owner (least open assigned issues first, LLM rank as
tiebreaker) instead of only high-priority ones. Drops the now-unused
priority/type branch from the shell gate.
Also refresh .github/areas.json ownership:
- remove SabhyaC26 from all areas
- add PattaraS to harness-antigravity (keeps it at the 2-owner minimum)
- reactivate dbczumar (owners_paused -> owners) across their areas
Co-authored-by: Isaac
* fix(web): remount terminal view when switching same-vendor sessions
Two sessions of the same shape share a fixed agent-terminal id (e.g. every
claude-native session's `terminal_claude_main`, every SDK session's
`terminal_tui_main`). ChatPage stays mounted across a session switch and only
feeds MainTerminalView / TerminalsPanel a new conversationId, so keying the
xterm wrapper on the terminal id alone let React reuse the existing mount —
the pane kept the previous session's 20k-line scrollback until the new
WebSocket reconnected and tmux repainted. The stale history cleared only on a
manual refresh.
Scope the wrapper key to `${conversationId}:${terminalId}` in both surfaces so
a session switch forces a clean remount (fresh xterm + WebSocket, no stale
buffer). Add regression tests that switching conversationId with the same
terminal id remounts the TerminalView.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(web): assign terminal mount id once per mount
Copilot review flagged that useRef(++terminalMountSeq) evaluates the
increment on every render (useRef ignores the arg after first render),
so the module counter advanced on re-renders — contradicting the
comment. The read value (instance.current) was still stable, so the
assertion held, but assign the id conditionally so the counter tracks
real mounts.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
The Chat/Terminal switcher moved into the header as a Radix DropdownMenu
whose trigger toggles on pointer-down and carries a controlled hover
tooltip on the same node (ViewModeToggle.tsx). On a busy page — a live
terminal stream plus that tooltip re-rendering during the click — a lone
`.click()` occasionally nets the menu back to closed, so the follow-up
`expect(menuitemradio).to_be_visible()` times out. That is the observed
flake in test_codex_goal_mode and the native render-parity suites: the
failure snapshot shows `tooltip "Terminal view"` (rendered only while the
menu is closed) with no menu items.
Add a shared `_select_view_mode(page, option)` helper that reopens the
menu in a retry loop until the target radio item is actually visible, then
selects it, instead of trusting a single toggle click. Route
`_ensure_chat_view` and every native-parity `_open_terminal_view`
(codex, claude, goose, hermes, cursor, kiro) through it.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Repoint the gray text and border tokens onto the shadcn Zinc scale so the
UI's neutrals match the design system:
- Primary text (--foreground, --card-foreground, --secondary-foreground,
--sidebar-foreground) -> Zinc 800 #27272a
- Secondary text (--muted-foreground) -> Zinc 500 #71717a
- Default border (--border, --input, --sidebar-border) -> Zinc 200 #e4e4e7
- Strong border (--border-strong) -> Zinc 400 #a1a1aa
Also adds the two tokens the palette needs but the app lacked:
--border-weak (Zinc 150) and --foreground-tertiary (Zinc 400), exposed as
Tailwind utilities.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
The codex goal-mode + native-parity targets need the Codex-parity Rust
sidecar. flake-stress-ui.yml relied on the fixture's inline `cargo build`
at test time, capped by --timeout=300. On a cold Rust cache every parallel
attempt independently compiles the ~1100-crate tree and overruns the
per-test timeout, so all attempts die at fixture setup before the test
body ever runs — masquerading as a 100% failure rate unrelated to the
target under test.
Mirror e2e-ui.yml / ci.yml: add a dedicated build-sidecar job that
compiles the sidecar once (same main-scoped cache key so it usually
restores), uploads the ~10MB binary, and has each attempt download it and
set CODEX_PARITY_SIDECAR_BIN. build_sidecar_bin() then returns the prebuilt
path and skips cargo entirely. Drops the per-attempt Rust toolchain + target
-dir cache that never made the inline build fit the timeout.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
`dev/repro.py --public` sets `public: true` in the agent's input contract, and
the agent shares the session read-only (`sys_session_share __public__`) at the
start of its run so it is browsable live — useful when watching a run or
reproducing against a shared --server. Off by default (a local session is
already yours to browse).
- dev/repro.py: add --public; include `"public": true` in the payload when set.
- config.yaml: re-add `agent_session_sharing: public` to grant the __public__
capability (opt-in via the flag).
- AGENTS.md: document the `public` input; make sharing the first preflight step.
- README.md: document the --public flag.
Co-authored-by: Isaac
* dev/repro: add worktree-isolating driver script; clarify browser context
Add dev/repro.py — a maintainer-only wrapper around `omnigent run
dev/repro-agent`. It prompts for the bug URL (or takes it as an argument /
bare id like OMNI-1234 / 3987), creates an isolated git worktree off the
current checkout's HEAD (branch repro/<slug>, auto-suffixed on collision),
and runs the agent FROM that worktree so the authored e2e test lands on its
own branch without dirtying your checkout. The worktree is always kept; the
script prints its path + branch + cleanup command at the end.
It lives under dev/ (not shipped in the wheel) rather than as an `omni`
subcommand because it depends on a source checkout — the repro-agent authors
into tests/e2e_ui/ / tests/e2e/, which only exist here.
Also, from PR review:
- AGENTS.md: note that UI-journey reproduction drives the desktop app's
embedded browser, so it expects a desktop / embedded-browser context (fall
back to the backend path / needs_more_info when there's no browser pane).
- README.md: document the dev/repro.py driver.
Co-authored-by: Isaac
* dev/repro-agent: handle compound / multi-symptom bug reports
Ported from the internal repro-agent (omnigent-internal#24). A single bug
report often bundles several distinct symptoms (e.g. "picker unavailable AND
catalog defaults lag"), and they can have different truth on the running
build — one already fixed, the other still live. Averaging them into one
verdict hides the part that's still broken.
AGENTS.md now instructs the agent to:
- enumerate each claimed sub-symptom in Step 1 (don't collapse a compound
report into one journey),
- reproduce and judge each independently in Step 2, and
- roll up to an overall verdict where ANY live sub-symptom ⇒ reproduced
(already_fixed only when every facet is fixed), emitting a per-facet
breakdown (`facets`) in the output so a partial fix stays visible.
Wording adapted to the local variant (running build / local session; no
deployed-app or public-share references).
Co-authored-by: Isaac
* dev/repro: drop the `ref` input — always reproduce against the running build
`ref` never controlled what was validated: the agent always reproduces against
the app it is connected to (the running build / latest main), and `ref` was
only informational — and redundant, since the reported version is already in
the bug report the agent reads. Simplify the input contract to just `bug_url`.
- dev/repro.py: remove the --ref option; the payload is {"bug_url": ...}.
- config.yaml / AGENTS.md / README.md: drop the ref bullet/examples; keep the
guidance that reproduction is always against the running build (so an
old-version report can still land already_fixed).
Co-authored-by: Isaac
A developer-facing repro agent under dev/. Given just a bug (a bug_url — GitHub
issue or Linear ticket — plus an optional ref), it reconstructs the user journey
from the linked report, drives the running Omnigent app it is connected to (the
server `omnigent run` spins up, or one passed with --server) through that journey
until the failure happens live, and authors a durable e2e test (Playwright under
tests/e2e_ui/ for UI bugs, or tests/e2e/ for backend) as the regression artifact.
It reproduces against whatever app it is connected to and authors the test into
the current checkout, so a developer can run it against their own local server:
omnigent run dev/repro-agent -p '{"bug_url":"https://github.com/omnigent-ai/omnigent/issues/1234"}'
It does not fix the bug, merge, or push — it produces a live-confirmed
reproduction plus the test and hands off (the fix half owns the before/after
fail->pass proof).
Files:
- config.yaml — claude-sdk brain, os_env shell/file access, blast-radius guard.
- AGENTS.md — the operating procedure (confirm workspace -> reconstruct journey
-> reproduce live -> author the e2e test -> structured verdict).
- README.md — prerequisites, usage, and what it produces.
Co-authored-by: Isaac
omni host status was slow because _add_daemon_host_status made a
GET /v1/hosts/{id} request for every daemon record, including the many
stale records accumulated over dev sessions (39 in one measured case).
Dead processes can't have an online tunnel, so the correct answer is
host_status=offline with no network round-trip.
Skip the HTTP call when process=offline and set host_status directly.
This cut omni host status from ~14s to ~5s on a workstation with many
stale daemon records.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
When a session's workspace directory no longer exists on the host
(e.g. a worktree was deleted), the host was returning a generic
failed status with no error_code, causing the server to silently
wait out the full connect timeout and then surface a generic
'runner_failed_to_start' banner.
Changes:
- Add WORKSPACE_MISSING_ERROR_CODE ('workspace_missing') to host/frames.py
- Host returns this code when workspace.is_dir() fails, alongside the
existing descriptive error message
- Server (routes_events.py post_event) handles workspace_missing the same
way as harness_not_configured: immediately consumes the user message and
persists an actionable runner_failed_to_start error item with the host's
'workspace path does not exist: ...' message instead of timing out into
a generic RUNNER_UNAVAILABLE
- orchestration.py _ensure_runner_relay_ready skips the connect-timeout
wait for workspace_missing (same as harness_not_configured), and records
the refusal in runner_exit_reports so snapshot-based renders also show
the actionable cause
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): stop rendering shell-style env vars in prose as LaTeX math
Error messages like "Unresolved environment variable '$LLM_API_KEY' … Set
$LLM_API_KEY or $OMNIGENT_LLM_API_KEY" render through the assistant markdown
renderer, which has single-dollar math enabled. The paired `$` tokens collapsed
into a garbled inline formula.
normalizeExplicitMathDelimiters already escaped a lone `$` before a digit
(currency); extend that heuristic to also escape shell-style variable
references ($VAR_NAME and ${VAR_NAME}, SCREAMING_CASE) so they stay literal text
instead of flipping the math span.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* docs(web): clarify SHELL_VAR_RE handles single-char braced refs
Address Copilot review: the comment said "2+ chars" but the braced
alternative uses `*`, so `${A}` matches. That's intended — braces
disambiguate a variable reference, so one char is enough there, while the
bare form still requires 2+ so `$X …` reads as inline math. Fix the comment
and add a test for both cases.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): require full-token boundary for bare shell-var match
Address Copilot review: SHELL_VAR_RE's bare branch matched a SCREAMING_CASE
prefix of a mixed-case token (e.g. `$FOOBar$`), escaping the opening `$` while
leaving the closing `$` as a delimiter — an unbalanced span that breaks
genuine inline math. Add a `(?![A-Za-z0-9_])` boundary so only full
SCREAMING_CASE tokens match, and greedy backtracking can't settle on a prefix.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
When the VPN drops, a corporate proxy answers the host tunnel's
WebSocket upgrade with 401/403 before the request reaches the Omnigent
server. `_classify_http_status` treated those as permanently fatal, so a
live, already-registered host exited with code 1 and the user had to
re-run `omnigent host` after reconnecting.
A host that already completed an upgrade proved its credentials and
authorization are valid, so a later 401/403 is almost always a transient
network-path artifact. For a connected host, 401/403 now retries forever
via the normal reconnect path (mirroring the existing login-redirect
design), with a once-per-outage stderr notice so a foreground
`omnigent host` isn't silent. A fresh, never-connected host still fails
loud on the first 401/403.
Fixes OMNI-2367.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Two fixes to _ManagedMintTokenFactory and _InitialAuthTokenFactory:
1. Only latch declined=True on 400/404 if the factory has never successfully
minted a token. A 400 mid-session (e.g. during an IP ACL flip) is
transient — the server already proved it mints for this runner, so treat
it like any other transient failure instead of bricking the factory.
2. Add a declined property to _InitialAuthTokenFactory that proxies the
inner fallback factory. Without this, auth_flow sees declined=False on
the outer wrapper and raises 'no token' instead of falling back to bare
requests, causing infinite retry loops in PATCH external_session_id and
other callbacks after the inner factory latches declined.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
A managed-host wake (resume_managed_host: resuming a dormant resumable
sandbox on the next message) never forwarded launch-pipeline stages to the
caller, unlike the fresh-launch path (_arm_and_start_host), which threads
on_stage through. As a result _run_managed_wake left the session on the
single "provisioning" band that _kick_managed_wake seeded for the entire
resume — even while the host was already re-execing and dialing back — so
the UI showed a frozen "Provisioning sandbox" band for the whole wake.
Thread on_stage through resume_managed_host into _start_sandbox_host (which
already accepts it), and have _run_managed_wake pass a _publish_sandbox_status
closure. The wake now advances to "starting" (emitted by base start_host)
before "connecting"/"ready", matching a fresh launch.
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
Connecting a host to a Databricks-App-deployed omnigent server as a service
principal failed: `omni run --server <app>` resolves credentials through the
Databricks SDK's default chain, which reads only the DEFAULT ~/.databrickscfg
profile. When DEFAULT points at a different workspace than the one fronting the
app, the minted token is for the wrong workspace and the Apps proxy bounces the
request to interactive OIDC (302) instead of admitting it.
Add a `--profile NAME` option to `omni run` that sets DATABRICKS_CONFIG_PROFILE
for the CLI process, so every remote-auth path (_remote_headers, _server_auth,
_DatabricksTokenAuth) resolves the named service-principal profile. This enables
headless M2M access to a deployed app without a prior interactive `omnigent
login`. An explicit --profile wins over an ambient DATABRICKS_CONFIG_PROFILE;
omitting it leaves any preset untouched.
Prereq (Databricks-side, not code): the service principal must have CAN USE on
the app.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(runner): treat HTTP 403 as refreshable on tunnel reconnect
A runner whose auth token expires while the machine is offline can
receive HTTP 403 (not 401) when DNS resolves again and the server
rejects the stale credential. Previously 403 was in
_FATAL_SERVER_HTTP_STATUSES and caused the runner to exit immediately
with no retry, killing any active session.
Move 403 into _REFRESHABLE_HTTP_STATUSES alongside 401. The existing
_handle_refreshable_auth_failure path already handles this correctly:
it attempts one token refresh, and if the factory is invalidatable
(or returns None) the second 403 raises a fatal RuntimeError instead
of looping forever. A runner with no factory still exits fatally on
the first 403.
Add three tests covering the new behaviour:
- 403 with factory → refresh → retry → success
- 403 with invalidatable factory → refresh → persistent 403 → fatal
- 403 without factory → fatal immediately
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): guard 403/401 refresh against transient factory errors
- Drop the inline to_thread(factory) call in the refreshable-status
handler; rely on the loop-top _refresh_auth_token instead, which
already wraps factory calls in try/except for OSError/ValueError.
This prevents a transient IdP error on wake-from-sleep from crashing
serve_tunnel rather than falling back and retrying.
- Also removes the redundant double-refresh-per-cycle that the inline
call introduced.
- Update _handle_refreshable_auth_failure docstring: 401/403 now go
through the streak path, not this function; only 302 redirects
reach it.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(server): import _spawn_archive_stop in routes_core
Missing import introduced in 2ce9c60b.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* perf(sessions): stop and archive in parallel so archiving isn't gated on stop timeouts
Archiving a live session took 5-10s: the sidebar serialized stop -> archive,
and the PATCH handler awaited its own best-effort stop (5s runner / 10s host
teardown ceilings per running session) before flipping the flag — even though
the archive proceeds regardless of the stop's outcome. Fire the client legs
in parallel and detach the server-side stop into a retained background task;
the stop still runs to completion, it just no longer holds the response.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(sessions): let the server own the archive stop so it can't race the client's
Review follow-ups on the parallel-archive change:
- The client no longer sends its own stop_session alongside the archive
PATCH. Two concurrent stops raced the same runner, and because the
runner's stop handlers are not idempotent (kill_session raises once
the pane is gone -> 503), the loser's failure aborted the client stop
before it reached the host-runner teardown -- orphaning a host-spawned
session's dedicated runner. Archive now sends one PATCH.
- The server's detached stop carries the host-runner teardown that only
the client stop used to do, so archiving still drops the runner's
tunnel and flips runner_online. Bulk archive gains this too; it never
sent a client stop.
- The stop is spawned only after the archived flag commits. It ran
ahead of later validations, so a PATCH rejected after that point
(reserved label, runner_id permission) could stop a session it did
not archive.
Adds an e2e_ui browser test for the archive flow plus server coverage
for the teardown and the rejected-PATCH case.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(server): file forked sessions into the source's project
Forking a session filed in a first-class project left the fork unfiled:
fork_conversation built the fork's metadata row without project_id. The
fork route now carries the source's project onto the fork, gated on the
forker owning that project (projects are owner-private, so a fork of a
shared session filed in someone else's project stays unfiled).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): refresh the project folder when a session is forked
A fork inherits the source's project, but the dialog only invalidated the
flat session list — each folder renders its own ["project-sessions", name]
query, which has no poll and converges only on an explicit invalidation.
The push stream can't cover it either: it skips the active session, and the
fork becomes active on navigate. So the clone stayed missing from its folder
until a reload or a re-navigation.
Adds an e2e regression test. It seeds the committed turn the fork action
anchors on straight into the store (new seed_committed_turn helper) instead
of driving a model turn, so it neither waits on nor inherits the flakiness
of the mock-LLM harness.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
web/electron/package.json was deliberately excluded from
update_versions.py because lockstep versions are not valid semver, so
it rotted: v0.7.0 and v0.8.0 shipped a desktop app still calling
itself 0.6.0, and 0.8.1's desktop bump had to be pushed by hand onto
the release branch (and still reads 0.8.0 at the v0.8.1 tag).
Stamp it with the semver translation of the lockstep version instead
(0.6.0rc1 -> 0.6.0-rc.1, 0.7.0.dev0 -> 0.7.0-dev.0, finals
unchanged) — semver orders these the way PEP 440 does (dev < rc <
final), so desktop auto-update comparisons stay correct. check() now
gates the translation, so a drifted desktop version fails the version
lockstep lint. Aligns main's desktop version to 0.9.0-dev.0.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): normalize uv.lock after /regen resolutions
The regen workflow was the one lock-writing CI path left out when the
normalize-then-verify step was added to release/bump/nightly: its
uv lock --upgrade-package runs re-add the size fields the canonical
form forbids, ballooning a /regen'd PR's lockfile diff by ~3k lines of
formatting noise and failing the pre-commit lint on the PR.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(ci): /regen upgrade touches only uv.lock
A targeted Python package upgrade was also deleting and re-resolving
pnpm-lock.yaml from scratch, burying a ~100-line dependency fix under
thousands of lines of npm churn. Plain /regen keeps refreshing both
lockfiles.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* Bump version to 0.9.0.dev0
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(deps): drop the stale gitpython cooldown exemption
The per-package cutoff (2026-07-24) was added to make 3.1.55 resolvable
while it was inside the P7D window; it aged out, and the frozen cutoff
now excludes 3.1.56/3.1.57, which fix GHSA-p538-c434-8v24 and
GHSA-3f7w-8rr8-f37f — so the OSV audit fails on any PR touching the
lock. The global P7D cooldown admits 3.1.57 on its own now. Lockfile
regen follows via /regen upgrade gitpython.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* chore(oss): regenerate public lockfiles against public PyPI/npm
* chore(oss): regenerate public lockfiles against public PyPI/npm
* chore(deps): normalize the lockfile back to canonical form
The /regen runs regenerate uv.lock without the normalize step the
other lock-writing workflows gained, re-adding the size fields the
canonical form forbids. Text-only cleanup; the resolved versions
(gitpython 3.1.57, aiohttp 3.14.2) are unchanged.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* chore(deps): restore main's pnpm-lock.yaml
The /regen runs regenerate the npm lockfile from scratch even for a
Python-only package upgrade; this PR changes no JS dependency, so
main's lockfile is exactly right for it.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
The auto-generated entry said no user-facing changes: the release's one
change is a cherry-picked revert whose PR is still open against main,
which the changelog curation (merged-PRs-in-range) cannot see.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The nightly cut is fully unattended, so a broken run blocks nobody and
consumers silently stop getting new builds. Add Nightly Release to the
failure monitor's watch list: its two-consecutive-failures rule and
close-on-green behavior apply unchanged, and skipped quiet nights
conclude success so they close any open tracking issue.
RELEASING.md gains a Nightly builds section: what the workflow does,
how consumers install and update from tags (no PyPI), and that a bad
nightly needs no recovery beyond fixing main.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(harness): add Grok Build (xAI) as a first-class ACP harness
Grok Build (`grok`) had no first-class harness — only usable as a custom `acp:`
agent or as `xai/grok-*` behind openai-agents. Add `harness: grok` (alias
`grok-build`) driving `grok agent stdio` over ACP via the generic AcpExecutor,
the reuse path the issue suggests (like qwen).
- inner/grok_harness.py: thin create_app wrapping AcpExecutor with a fixed
`grok agent stdio` command; auth is Grok's own (grok login / XAI_API_KEY),
Omnigent stores no credential.
- Registry: valid_harnesses / harness_modules / alias grok-build / capabilities
(ACP profile: own-auth, cold resume, SSE permission, interrupt) / label
"Grok Build" / HARNESS_GROK_MODEL.
- Install spec (curl x.ai/cli/install.sh, grok login --device-auth) and
binary-gated readiness, matching the other own-auth CLI harnesses.
Closes#2881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: apeltekci <andrew@peltekci.com>
* test(onboarding): include grok spellings in configured-harness-map test
The grok harness added `grok` + `grok-build` to the configured-harness map;
test_configured_harness_map_covers_all_spellings pinned an expected_keys set
that omitted them, so it failed with both as extra items. Add them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: apeltekci <andrew@peltekci.com>
* test(e2e): exclude grok from the live no-agent harness matrix
Registering grok as a coding harness added it to the matrix's expected set,
but grok is a headless ACP harness driven over stdio: it authenticates from
the grok CLI's own xAI login rather than the shared gateway/profile probe
wiring, so there is nothing for this binary-less no-agent matrix to probe.
Exclude it alongside goose, which is excluded for the same reason, and name
tests/inner/test_grok_harness.py as its coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: apeltekci <andrew@peltekci.com>
* fix(harness): drop the grok model-override claim nothing implements
The harness registered HARNESS_GROK_MODEL in model_env_keys, but the executor
never read it, so a spec model or /model pick was silently dropped rather than
applied — and the docstring pointed at a session/set_model path this harness
doesn't implement.
Remove the registry entry and the claim. Grok selects its model in its own CLI;
an Omnigent-driven override for ACP-backed harnesses is a separate concern and
should land with the mechanism that actually applies it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: apeltekci <andrew@peltekci.com>
* refactor(harness): declarative catalog for builtin ACP CLI harnesses
Promoting an ACP-speaking vendor CLI to a first-class harness has meant
touching 6+ registration points (capabilities, valid set, module map,
aliases, labels, install spec, readiness, setup steps, a per-harness
spawn-env builder, the live e2e matrix exclusion) plus a near-identical
thin inner module. Recent PRs each re-derived this by hand and one shipped
without its spawn-env builder, silently dropping the session cwd and the
spec sandbox.
Add omnigent/acp_cli_harnesses.py: one AcpCliHarness row per vendor CLI
(label, binary, ACP argv, aliases, install and login metadata). Every
registration derives from the row:
- harness_plugins: validity, module routing (all rows run the shared
omnigent/inner/acp_harness.py wrap), aliases, labels, capabilities
(the generic acp profile), install specs and install keys
- onboarding: one-click install allowlist (npm rows) and vendor-login
setup steps derive; readiness rides the existing install-key gate
- runtime/workflow: one shared _build_acp_cli_spawn_env forwarding the
session cwd and serialized os_env, shell-quoting the resolved binary
- runner dispatch: one membership check covers every current and future
row
- tests: readiness spelling lists and the live-matrix exclusion extend
from the catalog; tests/test_acp_cli_harnesses.py drives a fake row
through the builder and dispatch and asserts full registration per
real row
The catalog ships empty; the first rows land with the Grok Build (#3075)
and Qoder (#3560) PRs, each reduced to one dict entry plus docs.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* refactor(grok): ride the ACP CLI harness catalog, one row instead of hand wiring
Rebase the Grok Build harness onto the declarative catalog from
feat/acp-cli-catalog: the thin inner module, the per-registry entries,
the install/readiness edits, and the manual e2e-matrix exclusion all
collapse into one ACP_CLI_HARNESSES row carrying the same label, alias,
command, install hint, and login metadata.
Riding the shared builder also fixes two gaps the hand wiring had: the
session working folder and the spec os_env/sandbox now reach the grok
subprocess (grok_harness.py read HARNESS_GROK_CWD / HARNESS_GROK_OS_ENV
but nothing ever set them), and a resolved binary path containing spaces
survives the shlex-split command string.
Registration, spawn env, readiness gating, setup steps, and the live
matrix exclusion are asserted per row by tests/test_acp_cli_harnesses.py,
replacing tests/inner/test_grok_harness.py.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: apeltekci <andrew@peltekci.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat: add nimble_research builtin backed by Nimble Agent API v2
Add a nimble_research built-in tool that delegates a research task to a
Nimble Web Search Agent through the asynchronous Agent API v2: start a
run (POST /v2/agents/{agent_id}/runs), poll it to a terminal status on
a monotonic deadline, then fetch the cited result. The tool returns a
bounded JSON envelope - run id, output (text or structured JSON), and
trust metadata (confidence, sources, per-claim citations) - capped so a
large result cannot blow the model context.
The builtin registers like web_search: a registry factory plus
runner-local dispatch, so a non-OpenAI model's nimble_research call
resolves to the backend. api_key and agent_id come from spec config
(the tool never creates agents; one-time bootstrap is documented in the
module); errors are returned as strings and always carry the run id,
including timeout, failure, cancellation, and unknown-status paths.
Polling honors Retry-After on 429 and retries transient failures within
a bounded budget; run creation is never retried.
Includes unit, dispatch, and e2e tests (respx transport mocks and a
fake-clock seam; the e2e drives the full lifecycle against a local
Agent API stub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* feat: add nimble_extract builtin backed by Nimble Extract Templates
Add a nimble_extract built-in tool that runs one of the account's Nimble
extract templates (POST /v2/extract/templates/run) and returns the
template's structured, parsed results as JSON in one synchronous call.
The template is named in spec config; the LLM supplies the template's
params (each template declares its own input schema, discoverable via
GET /v2/extract/templates/{name}).
This is the migration target for the deprecated one-call /v1/agent
site-scraping path: same structured-entities output contract, now on
the current Extract Templates API. The predecessor tool name is retired
rather than aliased - the registry does not reserve it, and a test
locks that in - so the old name can never silently point at a
different API.
Wiring mirrors nimble_research: registry factory plus runner-local
dispatch. api_key and template come from spec config; errors are
returned as strings with the template named and the server's task id
preserved for supportability (parsing failures, template-not-found,
params rejection, and server error bodies are each mapped to clear
messages); output is capped to keep the model context bounded.
Includes unit, dispatch, and e2e tests (respx transport mocks; the e2e
drives the flow against a local Extract Templates stub), with
captured-request assertions that every request carries the
X-Client-Source header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix(nimble_research): harden malformed-config and envelope bounds
Catch httpx.InvalidURL when building the run URL so a control character
in the configured agent_id returns the builtin's own clean error string
instead of escaping its documented never-raises contract (agent_id is
interpolated into the run URL path).
Cap each API-supplied trust string - reasoning, source and citation url
and title, and the output type - so a single oversized value cannot
inflate the returned envelope past its intended bound, matching the
list-length caps already applied to sources, claims, and citations.
Adds tests: a control-char agent_id returns an error with no request
made, and an oversized trust.reasoning is capped with the envelope
still valid JSON.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix(nimble_research): complete the never-raises and envelope bounds
Follow-up to the previous hardening pass, which covered only part of each
surface.
Never-raises: httpx.InvalidURL is not a subclass of RequestError, so it
also had to be handled on the poll and result hops. The run id comes from
the API and is only prefix-validated, so a control character after the
prefix could raise out of the tool. Polling treats it as permanent and
returns immediately rather than spending its transient-retry budget on an
error that cannot become valid.
Envelope bounds: cap the remaining API-supplied strings that reached the
envelope uncapped - trust confidence, per-claim path and confidence - and
drop a non-string source or citation url instead of passing the raw value
through. Also cap API-supplied text reflected into error strings, which
could otherwise be arbitrarily long.
Adds regression tests for both hops, for every capped field, for the
dropped non-string url, and for an oversized server error message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix(nimble_research): bound run status, run id, and the trust section
The result body's run status was accepted as any string and interpolated
raw into the failure message, so a malformed status could turn into a
multi-megabyte error string. Only a known terminal status is trusted now,
and the message caps the values it reflects.
Bound the accepted run id at creation instead of echoing an arbitrary one
through later messages, and cap the trust section as a whole: the
per-field caps still multiplied across sources, claims and citations.
Includes regression tests for each bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* feat(nimble_research): adopt nimble-python 1.2 typed run fields
Create runs through the released nimble-python 1.2.0 client instead of
hand-rolled requests, and expose the typed per-run fields it added.
agent_id is now optional and selects the create route: when it is omitted
the run is created with agents.run() so Nimble provisions the agent, and
when it is set the run is created with agents.runs.create() against that
agent. Both routes forward input_data, output_schema, sources, agent_name,
skill, and use_case as typed arguments, so no extra_body escape hatch is
needed. The client is built with max_retries=0, because creating a run is
billable and not idempotent and the API exposes no idempotency key.
effort stays an optional override, so leaving it unset lets the selected
agent or template default apply. low, medium, high, and x-high are
selectable per run. max is a coming-soon custom-budget tier: it stops with
a pointer to the Nimble product team, and only degrades to x-high when a
spec opts in explicitly. The degradation is reported on every outcome, so
a run that was downgraded and then failed still says so.
The agent id returned by creation addresses the rest of the lifecycle,
since on the generated route it is the only one that exists, and a run
that comes back owned by a different agent is rejected rather than
retargeted. Identifiers are checked against an allowlist before they are
interpolated into a request path. Status polling defaults to ten seconds.
Includes unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix(nimble_research): warn against resubmitting an unresolved create
A create that fails in transport or times out may still have been received,
and a 408 or 5xx reached Nimble before the failure was reported, so the run
can be live and billed while the call reports an error. A 202 carrying an
unusable body is the settled version of the same problem: the run exists,
but the response cannot address it.
All of these now say so and tell the caller not to resubmit, since a
resubmission pays for the task a second time. The guidance names the run id
when one survived, and points at the account's recent run history when none
did. A clear rejection still carries no such warning: 401, 403, 404 and 422
create nothing, and attaching the warning to them would only teach the
reader to skip it.
Includes unit tests for the ambiguous and settled paths, and for the
rejections that must stay silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix(deps): upgrade GitPython to 3.1.55 to clear advisories
The lockfile pinned GitPython 3.1.50, which carries eight advisories whose
fixes land across 3.1.51, 3.1.53, 3.1.54 and 3.1.55. The dependency audit
only runs when the lockfile changes, so the pin was invisible until it was
touched, and then it failed the scan.
3.1.55 is the first release that clears all eight. It sits one day past the
P7D resolution window, so it needs a per-package exception alongside the
existing ones; the cutoff is set to land on 3.1.55 rather than the latest
release, keeping the change to the smallest version that resolves the
advisories.
GitPython is a transitive dependency, so this is a lockfile-only change and
no declared requirement moves. No other package version changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix: align Nimble 1.2 run controls with released contract
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* fix(nimble_research): never invite a resubmit of a billed run
Once run creation succeeds the run exists and has been billed, but only the
create path said so. The 409 branch told the caller to "retry the task to
fetch it" — on a run already observed complete — which reads as an instruction
to call the tool again and pay for a second run to read the first one's
result. Timeout, polling and result-fetch failures said nothing either.
Every post-create failure now ends with the same guidance the create path
gives, keyed to the run id: do not resubmit, reconcile the run that already
exists. A create-time 429 stays a clear rejection, since a rate limiter
refuses the request before a run is started; that classification is now
documented and covered.
Also drops the notice channel left behind when the effort downgrade was
removed. _resolve_effort returned None for it at every exit, so the value was
always None and the code that consumed it was unreachable; a resolved effort
is now simply what the caller asked for. The tool schema's sources object is
tightened to match what the tool already enforces, so a schema-conformant call
is not rejected at runtime.
Includes unit tests for each post-create path and the rejection that must stay
silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
* feat(onboarding): advertise the nimble builtins to the agent builder
list_builtin_tools.py is the onboarding assistant's sole source of truth
for recommendable builtins; without these entries the assistant can
never surface nimble_extract or nimble_research when building an agent.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* refactor(deps): move nimble-python behind a `nimble` extra
nimble-python was a baseline dependency, so every install pulled a
partner SDK that only the nimble_research builtin uses (nimble_extract
talks raw httpx). Follow the hindsight-client pattern: the SDK moves to
an optional `nimble` extra, nimble_research imports it lazily inside
_start_run and reports which extra to install (checked before anything
is sent, so nothing is billed), and the onboarding catalog advertises
the tool only when the SDK is importable. The client stays in the dev
set so the credential-free suites keep driving the real SDK, and mypy
gets the same ignore_missing_imports override as the other lazy-import
extras.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(nimble): address Polly review findings
Blocking items, all verified before fixing:
- Guard use_case with isinstance before the frozenset membership test; a
list/dict argument raised TypeError (unhashable) out of invoke(),
breaking the never-raises contract. Now a clear tool error, unbilled.
- Catch APIError (e.g. APIResponseValidationError, which subclasses
APIError, not APIStatusError/APIConnectionError) in the create path
and route it through the unresolved-create guidance: a 2xx whose body
fails SDK validation means the run may exist and be billed, which is
exactly the case the do-not-resubmit warning exists for.
- Clamp each HTTP call's timeout to the remaining deadline via
_request_timeout, so a single create/poll/result request can no longer
overrun the tool's documented timeout_seconds budget.
Also apply the research module's error-string caps to nimble_extract
(message, task id, parsing detail, status), closing the one reflected
uncapped path Polly's non-blocking notes and the maintainer review both
flagged. Regression tests for all four.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
The release cut, main bump, and nightly cut all regenerate uv.lock in
CI. The runner's uv now writes size fields on file entries, which the
repo's canonical lockfile form (scripts/normalize_uv_lock_registry.py,
enforced by the pre-commit hook) forbids — so the v0.8.0 release
commit went red on the branch-push lint run, and the next cut from
that branch would fail the green-CI gate. Run the normalizer after
uv lock (fixer exits non-zero when it rewrites, so tolerate that),
then hard-verify with --check so a genuinely broken lockfile still
fails the step.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Promoting an ACP-speaking vendor CLI to a first-class harness has meant
touching 6+ registration points (capabilities, valid set, module map,
aliases, labels, install spec, readiness, setup steps, a per-harness
spawn-env builder, the live e2e matrix exclusion) plus a near-identical
thin inner module. Recent PRs each re-derived this by hand and one shipped
without its spawn-env builder, silently dropping the session cwd and the
spec sandbox.
Add omnigent/acp_cli_harnesses.py: one AcpCliHarness row per vendor CLI
(label, binary, ACP argv, aliases, install and login metadata). Every
registration derives from the row:
- harness_plugins: validity, module routing (all rows run the shared
omnigent/inner/acp_harness.py wrap), aliases, labels, capabilities
(the generic acp profile), install specs and install keys
- onboarding: one-click install allowlist (npm rows) and vendor-login
setup steps derive; readiness rides the existing install-key gate
- runtime/workflow: one shared _build_acp_cli_spawn_env forwarding the
session cwd and serialized os_env, shell-quoting the resolved binary
- runner dispatch: one membership check covers every current and future
row
- tests: readiness spelling lists and the live-matrix exclusion extend
from the catalog; tests/test_acp_cli_harnesses.py drives a fake row
through the builder and dispatch and asserts full registration per
real row
The catalog ships empty; the first rows land with the Grok Build (#3075)
and Qoder (#3560) PRs, each reduced to one dict entry plus docs.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): nightly prerelease builds — tag-only dev-datestamp cuts from main
Adds nightly-release.yml: every night at 04:30 UTC it walks main to the
newest commit with completed green CI, stamps the lockstep version to
X.Y.Z.devYYYYMMDD, commits the stamp detached on top of that base, and
pushes only the tag via the omnigent-ci App token. Quiet nights (no new
commits since the last nightly tag) and same-day reruns no-op.
Deliberately not the release.yml flow: no release branch, no main bump,
no benchmark gate. Downstream is already dev-quiet: no GitHub release,
no notes, no changelog, no homebrew; images publish the immutable
version tag; update-check ignores dev releases and omni upgrade --pre
opts in. The datestamp is fixed-width because PEP 440 compares the dev
segment as one integer — a wider stamp would sort above every narrower
one forever.
PyPI publishing follows separately via the secure release repo's
scheduled lane.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(ci): nightly consumer update script; drop the stale PyPI hand-off note
scripts/update_nightly.sh resolves the newest vX.Y.Z.devYYYYMMDD tag
(version sorts before date, so the first nightly after a main version
bump outranks all older ones; the 8-digit date requirement screens out
legacy .dev0-style tags) and installs it with uv, pinning the lockstep
trio to that one tagged commit. Idempotent, so it is cron-safe: it
exits fast when the newest nightly is already installed instead of
redoing the web-UI build.
The workflow header no longer claims the secure release repo publishes
nightly tags to PyPI: that lane was dropped, nightlies are consumed
straight from the tag.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* feat(cli): omni upgrade --nightly moves onto the newest nightly tag
Nightlies are vX.Y.Z.devYYYYMMDD git tags that never reach the package
index, so the flag answers 'is there something newer' from the repo's
tags (git ls-remote + PEP 440 max, so the first nightly after a main
version bump outranks all older ones) and reinstalls with a git spec
pinned to that tag, per installer (uv/pipx/pip/poetry). It dispatches
before the VCS-vs-registry split: a registry install hops onto the
channel, and a VCS install pinned to an older nightly moves tags
instead of re-pulling its pinned ref. Same drain/stop, --check, and
probe-the-disk verification contracts as the release path.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
## Related issue
N/A
## Summary
- After the switch to corepack/pnpm, `pip install .` / `uv sync` could hang
indefinitely for users who have a corepack `pnpm` shim on PATH but have
never downloaded pnpm. Corepack prints `! Corepack is about to download
.../pnpm-11.15.1.tgz` and then blocks on `? Do you want to continue? [Y/n]`.
Build backends capture output, so the prompt is invisible and the install
just sits there until the 600s timeout.
- The trigger is the shim, not the `corepack pnpm` fallback: corepack's
`dist/pnpm.js` does `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '1'` while explicit
`dist/corepack.js` uses `'0'`. `shutil.which("pnpm")` finds the shim, so the
prompting path is the one that looked fine. CI is unaffected because corepack
skips the prompt when `$CI` is set.
- Run both pnpm commands in `setup.py` with
`COREPACK_ENABLE_DOWNLOAD_PROMPT=0` (download without asking) and
`stdin=DEVNULL` so nothing else in the toolchain can block on input we can
never deliver. Applied the same fix to `tests/e2e_ui/conftest.py`, which had
the identical latent hang under captured pytest output.
## Test Plan
Reproduced the hang and verified the fix against the pinned `pnpm@11.15.1`,
handing the child a real TTY via `pty.openpty()` and an empty `COREPACK_HOME`:
```
BEFORE (shim default prompt=1, TTY stdin): HUNG (timeout)
err='! Corepack is about to download .../pnpm-11.15.1.tgz\n? Do yo'
AFTER (prompt=0 + stdin=DEVNULL): proceeds straight to download
```
End-to-end check of the install path:
```bash
rm -rf ~/.cache/node/corepack "$COREPACK_HOME"
corepack enable # pnpm shim on PATH, pnpm not yet fetched
rm -rf omnigent/server/static/web-ui
pip install . # previously stalled with no output
```
`ruff check` / `ruff format --check` clean on both files.
## Demo
N/A
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Verified manually: the failure only reproduces with a corepack `pnpm` shim, an
unpopulated `COREPACK_HOME`, and a TTY on stdin, so an automated test would
have to stand up a pty plus a registry fetch inside the build backend. Covered
instead by the pty-based before/after check in the Test Plan.
## Changelog
`pip install` / `uv sync` no longer hangs when pnpm is provided by a corepack
shim that has not downloaded pnpm yet.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
When omnigent-slack joined the lockstep, the cut job's hand-kept git
add list kept staging only the original five paths, so the release
commit shipped integrations/slack/pyproject.toml unstamped. At the
v0.7.0 tag the tree pins omnigent-slack==0.7.0 while the in-tree
package still says 0.7.0.dev0: uv sync --locked fails at the tag, a
source install with the slack extra cannot resolve, and lint went red
on both release/v0.7.0 pushes without blocking the tag. Stage with
git add -A like bump-version.yml so the staged set tracks whatever
update_versions.py stamps.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* Add WebSocket load test (dev/loadtest/) + run-load-test skill
Adds a Locust load test that opens N concurrent WebSocket connections to
WS /v1/sessions/updates and holds them open, measuring the server's
WebSocket fan-out (handshake, origin/auth gating, watch-set diffing,
heartbeat) under concurrency — no runner, LLM, or agent turns.
- dev/loadtest/ws_load_test.py: the locustfile (SessionUpdatesUser).
- dev/loadtest/run.py: runner taking server + host + load params, runs
locust headless, and writes a result set (summary.md, CSV, HTML, config).
- loadtest extra (locust + websocket-client) in pyproject.toml + uv.lock.
- .claude/skills/run-load-test: skill that gathers inputs, runs, and
explains the latency results.
Co-authored-by: Isaac
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
* Launch locust via sys.executable -m locust in the load-test runner
run.py launched locust as a bare `locust` command, which resolves through
PATH and can pick up a stale/broken locust from a different Python (e.g. a
~/.local 3.10 install missing gevent's zope.event) even when run.py itself
runs under a venv — crashing the run with ModuleNotFoundError before locust
starts. Launch it as `sys.executable -m locust` so it always uses the same
interpreter + site-packages that run.py runs under. Preflight now checks
importlib.util.find_spec (the actual interpreter) instead of shutil.which
(PATH), and --web execs sys.executable too.
Co-authored-by: Isaac
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
* Genericize --mount-prefix docs to reverse-proxy sub-paths
Replace deployment-specific mount-prefix details with a provider-neutral
"behind a reverse proxy at a sub-path" framing (neutral /omnigent example)
across the README, the run-load-test skill, and the run.py / ws_load_test.py
help + docstrings. The --mount-prefix flag itself is unchanged.
Co-authored-by: Isaac
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
* Add runner-level turn load test (real multi-turn conversations, mocked LLM)
turn_load.py drives real agent turns through the runner — the full
POST .../events → server → runner → executor → LLM → stream → idle loop —
under concurrency, with the LLM mocked (zero latency) so the numbers isolate
Omnigent's own per-turn / history-handling overhead. Runs N concurrent
conversations of M sequential turns each on one durable session, so history
grows across the turns (a real long conversation, not N one-shots).
It boots the whole stack itself (server + zero-latency mock LLM + runner) by
reusing the benchmark harness's BenchEnvironment, using the in-process
openai-agents harness — no vendor CLI, no real API key — so it runs from a repo
checkout with no server to point at. Concurrency is asyncio (the runner stack
is async), not Locust. Writes the same summary.md / run_config.json result
format as the WS runner.
Documents both scenarios (WebSocket fan-out vs runner turns) in the README and
the run-load-test skill.
Co-authored-by: Isaac
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
* Address review: fix socket leak, URL/timeout edge cases, double-count; add tests
Copilot review follow-ups on the load-test harness:
- ws_load_test: assign self.ws before the send/recv steps so a post-create
failure closes the socket instead of leaking it.
- ws_load_test: _ws_url now treats a schemeless host (localhost:8000, which
Locust accepts) as ws:// rather than emitting an invalid URL.
- ws_load_test: _read_until_snapshot caps each recv to the remaining deadline
so a late frame can't overrun by a full read timeout.
- ws_load_test: WS_READ_TIMEOUT falls back to the default on a non-numeric
value instead of raising in on_start.
- run.py: preflight websocket-client as well as locust; rename _fmt_ms ->
_fmt_num (it also formats Requests/s).
- run.py: _write_summary skips locust's Aggregated row when totaling, which was
double-counting the headline request/failure counts.
- docs: the scenario reads AUTH_TOKEN from the environment; drop the wrong
`-e AUTH_TOKEN` locust-flag examples (AUTH_TOKEN=... locust ...).
- tests: add tests/loadtest unit tests for the pure helpers (URL/env/argv
wiring, summary formatting, timeout parsing) — deterministic, no server boot.
Co-authored-by: Isaac
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
* Redesign as one load test: each user is a real host driving real turns
Collapse the two scenarios (ws_load_test.py + turn_load.py) into a single
load test where each Locust user IS a real omnigent host. Each user spawns a
real `omnigent host` subprocess (unique identity + per-host $HOME so the
host-daemon singleton guard doesn't collide), registers it over the host
tunnel, then creates host-bound sessions and drives real multi-turn
conversations — every turn is a genuine post→idle loop through a runner the
host spawns, with the LLM mocked (zero latency). `-u N` scales the number of
hosts; Locust does the concurrency.
run.py boots the whole stack (server + mock LLM via BenchEnvironment),
registers one agent, sets the mock reply, then runs Locust against it — there
is no --server to pass, since mocking the LLM requires a stack we control. It
reuses the CSV→summary.md machinery (Aggregated-row dedupe kept).
Capacity-limited by design: N hosts × M sessions = N×M real runner processes on
the load box, so it drives genuine end-to-end turns rather than faking the
runner, but does not scale to hundreds on one machine (documented). Removes the
websocket-client dep (no longer used); needs [loadtest,dev,agents-sdk]. README,
skill, and tests updated for the single scenario.
Verified locally: 5 hosts × 3 turns → 133 turns, 0 failures.
Co-authored-by: Isaac
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
---------
Signed-off-by: Shivam Mittal <shivam.mittal@databricks.com>
Co-authored-by: Shivam Mittal <shivam.mittal@databricks.com>
PR #3420 validated the OpenClaw Gateway ACP path end-to-end against a live
Gateway, but docs/openclaw.md still read as if streaming/final replies were
only protocol-matched and the integration provisional. Update the
compatibility section to state what live validation confirmed — streaming
assistant replies, native tool execution, ACP permission routing, and session
resume — and reframe the remaining Control-UI-sync gap as a known limitation
rather than an open question. Keep the note that CI cannot run OpenClaw.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* fix(ap-web): harden math rendering
Load KaTeX runtime styles in every web entrypoint and normalize common TeX delimiters so streamed formulas, radicals, and display math render reliably across chat surfaces.
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: buyicoder <169354621+buyicoder@users.noreply.github.com>
* fix(ap-web): make math delimiter normalization region-aware
Address Polly review notes on the math-rendering hardening:
- Skip normalization inside existing $…$/$$…$$ spans and treat a
literal backslash-backslash as a verbatim escape, so a LaTeX line break
like \\[1em] inside an aligned display block is no longer mistaken for
a \[ opener and corrupted.
- Track backtick-run length so \(/\[ inside a multi-backtick inline-code
span is left verbatim.
- Correct the stale FILE_PATH_AWARE_COMPONENTS comment now that the memo
comparator is gone and MessageResponse shallow-compares props.
Co-authored-by: Isaac
* fix(ap-web): guard currency dollars and indented fences in math normalizer
Follow-up on Polly review notes:
- A single $ immediately before a digit reads as currency ($5), so it is
escaped and does not flip the math-span toggle. Prevents prose like
"it costs $5 or $10" from parsing as inline math now that
single-dollar math is enabled globally. An escaped \$ is copied verbatim.
- Fence detection now allows CommonMark's 0-3 leading spaces and matches the
full fence run, so an indented ```-fenced block containing \(...\) is not
normalized (and a 4-backtick run no longer leaks into inline-code tracking).
Co-authored-by: Isaac
* fix(ap-web): use String.match for fence detection to clear exfil scan
The security Exfil scan flags RegExp.prototype.exec() because its text-only
regex matches the substring 'exec(', which is meant to catch Python dynamic
code execution (exec/eval/__import__). This is a pure in-memory regex match
against local string data, so switch to the equivalent String.match(), which
returns the same match array for a non-global regex and avoids the token.
Co-authored-by: Isaac
* fix(ap-web): address Copilot review on math normalizer and styles
- Track the opening fence marker so a fenced code block closes only on a
matching fence char with a run at least as long (CommonMark). A stray
`~~~` line inside a ```-fenced block no longer flips the fence off and
lets math normalization run inside code.
- Drop the no-op `overflow-y: visible` on `.katex-display`; with a
non-visible overflow-x the browser computes overflow-y as auto anyway, so
it only risked stray vertical scrollbars.
- Resolve the entrypoint-style guard test's paths from import.meta.url
instead of process.cwd() so it doesn't depend on the runner's directory.
Co-authored-by: Isaac
---------
Signed-off-by: buyicoder <169354621+buyicoder@users.noreply.github.com>
Co-authored-by: zhanyongjie <zhanyongjie@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Serena Ruan <serena.rxy@gmail.com>
* fix(pi): surface credential resolution error when gateway provider's env var is unset
When a `kind: gateway` provider is configured as the pi harness default
via `default: pi` and its `api_key_ref: env:VAR` cannot resolve (because
VAR is not exported in the runner's environment), `_optional_provider_family`
previously caught the OmnigentError from `resolve_secret` and returned None
silently. The outer `_apply_provider_to_pi` then raised a generic
"no family whose credentials resolve — set the api_key env var for its
'anthropic' or 'openai' family" message with no mention of which specific
variable to export, making the error hard to act on.
Change `_optional_provider_family` to return the captured error alongside
None (as a tuple), and surface that error in the "no family resolves"
message so the user sees exactly which env var (e.g. `$MY_TOKEN` from
`api_key_ref: env:MY_TOKEN`) needs to be set.
The design intent of the silent catch is preserved: a family whose key is
unset is still treated as absent so pi can fall back to the other family
when only one key is exported. The only change is that the fallback-failure
error now carries the root cause.
Closes#3788
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* address review: fix return type annotation, correct keychain docstring, remove issue refs from tests
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): forward provider api_key_ref env vars into runner subprocess
_build_runner_env filters the host environment before spawning the runner
subprocess, passing only an allowlist of known credential vars
(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.). A user who configures a gateway
provider with a custom env var via api_key_ref: env:MY_TOKEN would find that
MY_TOKEN is present in their shell and daemon process but stripped before
reaching the runner — resolve_secret then fails, _optional_provider_family
returns None for the family, and _apply_provider_to_pi raises the no-family-
resolves error.
Add provider_credential_env_vars(config) to provider_config.py, which scans
all inline-family providers for api_key_ref: env:VAR and api_key: $VAR
references and returns the set of env var names (plus OMNIGENT_-prefixed
aliases). Wire this into _build_runner_env so those vars are automatically
forwarded alongside the standard HARNESS_CREDENTIAL_ENV_VARS, without
requiring users to list them in OMNIGENT_RUNNER_ENV_PASSTHROUGH by hand.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(pi): add authHeader to generic openai provider entries in models.json
Generic (non-Databricks) OpenAI-compatible gateways expect
Authorization: Bearer <token>. The 'databricks' and 'databricks-completions'
provider entries in the generated models.json were missing authHeader: True
on the generic provider path, so Pi used the Databricks-native auth scheme
instead — causing a 401 Missing Authentication header from the gateway.
Add authHeader: True to both entries when is_generic_provider is true,
matching the pattern already used by databricks-openai, databricks-anthropic,
and databricks-mlflow.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(pi-native): qualify namespaced model ids in --model arg to prevent builtin routing
When a gateway provider's model id contains a slash (e.g. an OpenRouter
namespaced id like 'moonshotai/kimi-k2.5'), Pi's arg parser treats
'provider/model' in --model as a provider override, routing to the builtin
'moonshotai' provider instead of our custom 'omnigent' provider. The builtin
has no API key, producing 'No API key for provider: openai-codex'.
Pass the fully-qualified 'provider/model' form (e.g.
'omnigent/moonshotai/kimi-k2.5') when the model id contains a slash, so
Pi's findExactModelReferenceMatch matches the canonical form under our
provider first.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(worktree-guard): switch to posixpath for path normalization to ensure consistent behavior across platforms
* Windows-only escape in worktree_guard, the sole write confinement for unsandboxed workers: it reasoned in POSIX but normalized with os.path, which is ntpath on Windows and rewrites / to \ — so startswith("/") never fired and /etc/passwd returned ALLOW.
Fixed by normalizing with posixpath explicitly, plus a drive-letter reject for C:/Windows/x, which posixpath reads as an ordinary relative dir named C:.
Two follow-ups from Copilot: the drive check ran on the raw path, so ./C:/… (and a/../C:/…) normalized past it — moved it after normalization; and isalpha() narrowed to ASCII, since Windows drives are [A-Za-z] and the Unicode form over-rejected.
109 passed on Windows, where four of those cases fail on main. Audited environment_filesystem.py:190 in the same pass — it pairs normpath with os.path.isabs, which holds on both platforms, so it needs no change.
* feat(web): move Chat/Terminal switcher into the header
Terminal-first sessions previously toggled between chat and terminal via
an in-page pill above the composer. Replace it with a MessagesSquare +
chevron icon button in the ChatHeader (next to the agent-info icon) that
opens a Chat/Terminal dropdown, freeing the composer area and keeping the
switcher with the other session controls.
The new ViewModeToggle reads the same TerminalFirstContext the pill did,
so behavior is unchanged: it self-gates for non-terminal-first sessions,
the iOS shell (native Liquid Glass bar), and rail-opened shell views, and
disables the Terminal option (with a spinner while starting up) until a
PTY is reachable. A tooltip names the current view. Removes the pill, its
dead CSS, and the now-redundant iOS keyboard guard.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): update e2e locators + a11y for header view toggle
The render-parity e2e helpers located the old in-page pill via
`role="group" name="View mode"` and clicked its inner Chat/Terminal
buttons. The header switcher is a dropdown, so point them at the
`view-mode-toggle` trigger and click the Chat/Terminal menuitemradio.
Also address review feedback on ViewModeToggle: import the shared
`TerminalFirstView` type instead of a duplicated union in the setView
cast, and only suppress dropdown close-refocus for pointer closes so
keyboard/AT users keep their place (mouse closes still avoid the stuck
ghost-button focus ring).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(codex-native): tear down app-server when TUI pane is reaped or exits
Each codex-native session (Polly dispatches every codex sub-agent this
way) runs two codex processes on the runner: the codex app-server backend
and the codex --remote TUI pane. Only DELETE /v1/sessions ran the full
cleanup that cancels the forwarder and closes the app-server. Two other
ways the TUI pane goes away left the app-server orphaned for the runner's
lifetime:
- the idle pane reaper closes the tmux pane after the idle window but
never touched _AUTO_CODEX_APP_SERVERS, and
- an unexpected TUI exit (crash / OOM / host recycle) evicted the pane
without cancelling the forwarder.
On a long-lived multi-session runner, every idle or crashed codex
sub-agent leaked a codex app-server process — the pile-up reported in
omnigents-qa.
Add teardown_codex_native_app_server(session_id): cancel the session's
forwarder (whose finally closes the app-server) and close any leftover
registered server. It's a no-op for a session with no registered codex
app-server, so it's safe to call from the shared pane-teardown paths for
every harness. Wire it into the reaper's reap and the terminal-exit
publisher.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(codex-native): reap codex app-server even if pane close raises
Move the codex app-server teardown in the idle-pane reaper into the
finally block. close_terminal() can propagate (TerminalInstance.close()
raises anything but TimeoutError), and in that partial-failure mode the
teardown line in the try body was skipped — leaving the exact orphaned
app-server this fix targets. The helper is idempotent and suppresses its
own errors, so running it in finally never masks the original exception.
Addresses Copilot review on #3925.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(codex-native): close app-servers on host/runner stop + boot reconcile
Host-spawned codex app-servers are spawned start_new_session=True, so
they survive their runner's death. Two gaps left them orphaned:
- On a graceful host/runner stop the host SIGTERMs the runner without a
per-session DELETE /v1/sessions, so per-session teardown never fired and
_stop_pm never closed _AUTO_CODEX_APP_SERVERS — every host-spawned codex
app-server leaked even on a clean stop. (The TUI panes were already
closed by the terminal registry's shutdown; only the app-server half
leaked.)
- On a hard death (SIGKILL / OOM / crash) nothing runs at all, and the
crash-safe registry was only reconciled when a NEW codex session
started — so orphans lingered until the next codex launch, if ever.
Add teardown_all_codex_native_app_servers() and call it from _stop_pm so a
graceful stop takes the app-servers down with the runner. Add a boot-time
reconcile_codex_native_process_registry() in _start_pm so a fresh runner
reaps orphans a dead predecessor left (owner-lock held => live sibling,
skipped). Reconcile runs in a thread since it does blocking file/PID work.
The --remote TUI self-exits when its app-server dies (observed: every
orphan seen in the field was an app-server, zero orphaned TUIs), and the
graceful path already closes TUI panes, so no tmux-name plumbing is added.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(cli): add `omnigent diagnose` environment snapshot
Add a read-only `omnigent diagnose` command that prints a small, secret-free
environment snapshot for bug reports: CLI version, OS/Python, and the server's
auth mode. With `--server <url>` (or a resolvable configured/local server) it
reads the server version and real auth mode from the unauthed `GET /v1/info`
endpoint, so version skew between CLI and server is visible — the same reason
the session-info popover shows `server · host`.
The auth mode doubles as the OSS-vs-managed signal: accounts | single_user |
oidc | header, derived from `/v1/info` when the server is reachable and falling
back to the local environment otherwise (tagged by `auth_source_origin` so the
two are never confused). The snapshot carries no secrets — only versions, OS,
and the coarse auth mode.
`omnigent doctor` (install-ledger migration) is left untouched.
Co-authored-by: Isaac
* fix(cli): address diagnose review — redact server_url, e2e test, help caution
Review follow-ups on the `omnigent diagnose` PR:
- Redact userinfo and query/fragment from the reported `server_url` so a
`--server https://user:pass@host` value can't leak credentials into the
snapshot (the "safe to paste into an issue" invariant).
- Add CLI-level tests (CliRunner + respx over /v1/info) exercising the command
wiring and output format end-to-end, alongside the existing unit tests.
- Note in `--help` that `--server` should point only at a trusted server, since
reaching a managed server may attach stored/ambient credentials to the request
(same behavior as `session export` / `run --server`).
Auth is intentionally still attached to the /v1/info probe: a managed server
sits behind an auth proxy that 401s an unauthenticated request, so dropping it
would break the OSS-vs-managed signal for exactly the managed case. Attaching
credentials to the request does not put secrets in the output, which is what the
"secret-free" guarantee covers.
Co-authored-by: Isaac
* fix(cli): harden diagnose URL redaction + register in subcommand allowlist
- _redact_url: fix two leaks the review found. Scheme-less inputs with userinfo
(`user:pass@host:6767`) were returned unchanged because urlsplit reads the
`user:` as a scheme — now scrubbed. IPv6 literals lost their required `[...]`
brackets when netloc was rebuilt from hostname/port — now the userinfo is
dropped off the authority in place, preserving brackets and host casing.
- Add `diagnose` to `_CLICK_SUBCOMMANDS` so `omnigent diagnose` is reachable
from main() (a registered command missing from the allowlist is rejected as
removed ad-hoc chat). Fixes test_click_subcommands_allowlist_covers_registered_commands.
Co-authored-by: Isaac
* fix(cli): make diagnose URL redaction leak-proof on malformed/scheme-less input
Follow-up on review: _redact_url used urlsplit, which raises ValueError on a
malformed IPv6 URL (the fallback then returned the raw string, leaking any
user:pass@) and left query/fragment intact on scheme-less inputs. Rewrote it as
pure string surgery — cut at the first ?/#, then drop a user:pass@ prefix from
the authority — so credentials and tokens are stripped uniformly regardless of
URL shape, with no parser that can raise. IPv6 brackets and host casing are
preserved.
Co-authored-by: Isaac
The Projects group-header kebab (⋯) rendered next to "New project" even
when its menu had no items to show. With no projects filed, neither the
expand/collapse controls (need projectNames.length > 0) nor "Select
sessions" (needs project sessions) apply, so the menu opened empty.
Gate the kebab on whether either item is available, leaving only the
"New project" button when there's nothing to offer.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
The "My sessions" / "Shared with me" tabs were hidden whenever bulk
selection mode was active, stranding the viewer on whichever scope they
happened to be on. Keep the tabs visible during selection so the scope
stays switchable.
Selection is a single global set while the tabs show disjoint,
ownership-scoped slices, so changing the visible tab now exits selection
mode — otherwise the bulk-action bar would show a stale count carried
over from the other tab. This is centralized in a `switchTab` helper used
by both the tabs' onValueChange and the "New session" snap-back (which
sets the tab outside Radix's onValueChange path), so no tab change can
skip the selection cleanup.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* Add product-analytics abstraction to web frontend
Introduce an opt-in, host-injected analytics seam so an embedding host can
collect user actions (clicks, field value-changes, page views) keyed by a
stable componentId. Fully inert standalone: when no host sink is configured
via OmnigentHostConfig.analytics, every emit is a no-op.
- lib/host.ts: OmnigentAnalyticsEvent type + analytics? sink + getter.
- lib/analytics.ts: emitOmnigentAnalytics, useOmnigentAnalytics
(trackClick/trackValueChange, values redacted by default for PII), and
useOmnigentPageView (re-fires on pathname change, like the unified router).
- Button/Input: optional componentId prop that reports clicks/value-changes.
- lib/routing.tsx: optional componentId on Link (OmnigentLinkProps) so a
link can opt into per-link analytics; standalone strips it.
- App.tsx: central <PageView id> wrapper declares each route's page-view id
next to the route table; SettingsPage keeps its own hook (param-derived
settings.<section> id) as the escape hatch.
- Example componentIds: chat composer send, tasks search, sidebar
conversation switcher, settings "Back to Omnigent" link.
Distinct from lib/telemetry.ts (low-level OTEL HTTP tracing); this is
application-level user-action analytics.
Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
* Ci
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>
* feat(sessions): auto-connect a wakeable runner on shell create
Creating a shell from the web UI on a session whose runner had gone to
sleep dead-ended on a 502 ("no runner available"), even though the host
was still up and the next chat message would have transparently woken it.
Add `ensure_runner_connected`, which runs the same runner-acquisition
ladder `post_event` uses (wake a stale resumable managed sandbox, launch
a runner on a live host, or relaunch a managed sandbox) without the
message-specific side effects, and call it from `create_session_terminal`
before proxying. Wakeable states reconnect and the shell opens; a
non-host-bound stranded session or an offline external host still 502s
(the CLI reconnect path owns those).
Surface connect state on the "+" → Shell menu item: it stays enabled and
shows "Reconnecting…" with a spinner while the server wakes the runner on
a wakeable session, and is disabled + labeled "Offline" for states the
browser can't reconnect. Widen the menu so the longer label isn't clipped.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(sessions): wait the connect grace before relaunching on shell create
Address PR review: ensure_runner_connected went straight to
_launch_runner_on_host whenever no runner client resolved, so opening a
shell against a session whose runner was merely booting (tunnel not yet
registered) would spawn a second runner and orphan the booting one —
diverging from post_event, which it claims to mirror.
When the session has a pinned runner_id and a live host, first wait
_HOST_BOUND_RUNNER_CONNECT_GRACE_S for it to connect (racing a
host.runner_status query that cuts the wait short if the host reports it
gone), and only relaunch if it's truly dead. Also drop the unused
tuple binding at the call site (the proxy re-resolves the client).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(runner): allow managed mint in InitialAuthTokenFactory fallback
When a managed sandbox runner starts with a host-provided bearer
(_InitialAuthTokenFactory), and that bearer is rejected (401), the
fallback resolver was called with _allow_delegated_mint=False. This
blocked the managed-mint path entirely, leaving the runner with no
credential for its HTTP callbacks.
For managed runners (OMNIGENT_RUNNER_DELEGATED_AUTH=1 + binding token),
the fallback must be able to reach the managed-mint path after the
initial bearer expires — the same path used by runners that start
without a host bearer. Removing _allow_delegated_mint=False restores
this: SDK/OIDC auth still wins when present; managed mint is the
natural last resort for sandbox runners with no user credential.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): pass proxy bearer through managed mint so Apps proxy lets it through
The managed-mint endpoint (POST /v1/runners/{id}/token) is authenticated
by the runner's binding token, but on Databricks Apps deployments the
proxy layer sits in front and requires a valid Authorization header on
every request. With no bearer, the proxy returns 401 before the request
reaches Omnigent — the same symptom as the _allow_delegated_mint=False
regression, but a separate root cause.
Fix: thread an optional proxy_bearer through _make_managed_mint_factory,
_ManagedMintTokenFactory, and _mint_managed_owner_token, passed to
databricks_request_headers as the Authorization header. The initial
host bearer seeds it; after the first successful mint the minted JWT
replaces it as the proxy bearer for subsequent refreshes.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): reuse runner auth factory in codex discover-and-forward
_codex_discover_thread_and_forward was calling _make_auth_token_factory()
fresh, but RUNNER_INITIAL_AUTH_TOKEN is already popped from env by
runner startup — so the fresh call went straight to managed mint with no
proxy bearer, getting 401 from the Apps proxy before reaching Omnigent.
Fix: accept auth_token_factory at the call site, extracted from the
server_client's _RunnerDatabricksAuth (which already carries the correct
proxy bearer). supervise_forwarder also reuses it.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): store auth factory as singleton so all call sites share proxy bearer
Every _make_auth_token_factory() call after runner startup (harness setup,
terminal creation, forwarders) was building a fresh factory with no proxy
bearer, because RUNNER_INITIAL_AUTH_TOKEN had already been popped from env.
Each fresh factory hit the delegated-mint path, got 401 from the Apps proxy,
and left that call site with no credential.
Fix: store the factory built by serve_runner in a module-level singleton
(_runner_auth_factory). Subsequent _make_auth_token_factory() calls with
default args return it directly, so all call sites across orchestration.py
and app.py share the proxy bearer without any individual patching.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): fix __main__ vs omnigent.runner._entry module identity
When the runner runs as python -m omnigent runner, _entry.py executes
as __main__, creating a module object separate from omnigent.runner._entry.
Two bugs:
1. _runner_auth_factory was set on __main__ but read from
omnigent.runner._entry (always None). Fix: set it on the canonical
module via import omnigent.runner._entry as _self_module.
2. isinstance(server_client.auth, _RunnerDatabricksAuth) was False
because server_client.auth is __main__._RunnerDatabricksAuth while
the check used omnigent.runner._entry._RunnerDatabricksAuth. Fix:
use getattr(server_client.auth, _factory, None) instead.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(runner): drop auth_token_factory param from codex discover-and-forward
Now that _make_auth_token_factory() returns the runner singleton (which
carries the proxy bearer), the explicit param and the server_client auth
introspection that fed it are no longer needed.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(runner): introduce _set_runner_auth_factory to set singleton
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: use sys.modules to set singleton, restore docstring, remove dup comment
- Replace self-import with sys.modules lookup to avoid the module
importing itself (also sets on __main__ as a fallback).
- Move singleton early-return to after the docstring so __doc__ is
preserved on _make_auth_token_factory.
- Remove duplicated comment block in _codex_discover_thread_and_forward.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: import canonical module before setting singleton to ensure sys.modules registration
sys.modules.get() returns None when running as __main__ because the
canonical name isn't registered yet. Importing it first forces
registration, then both the canonical module and __main__ get the
singleton set.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: shorten overlong docstring in test
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: reuse singleton when server_url matches runner URL
Callers like native_policy_hook.py pass server_url explicitly but still
want the shared factory. The singleton guard now matches on both None
and the runner's own RUNNER_SERVER_URL.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
rendered.count("\n") undercounts when Rich wraps a long status label
(e.g. "✓ Isaac-Databricks-Ai-Gateway") across multiple terminal rows.
The cursor-up escape then doesn't move far enough, leaving stale menu
frames in the scrollback — which makes the "Configure harnesses" block
appear to stack on every loop iteration.
Replace the newline count with _count_terminal_lines(), which strips ANSI
escapes and uses ceiling division of each line's cell width by the terminal
width to count actual visual rows.
Tests cover no-wrap, wrapping, exactly-full-width, ANSI stripping, and the
empty-string edge case.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
The claude-native session's Working/idle badge is driven by diffing the
tmux pane (the PTY watcher in resource_registry). That heuristic can't
tell "blocked on a prompt" from "working", and only flips to idle after
~1s of pane quiescence rather than on the real turn edge.
Claude Code writes a per-process status file at
`<config_dir>/sessions/<pid>.json` (its internal "concurrentSessions"
registry, present since v2.1.139) whose `status` flips idle/busy/waiting
on the actual turn edges. Prefer that for the claude-native running/idle
status, falling back to the PTY watcher when the file is absent (old
Claude, missing config dir) or never resolves.
- New `omnigent/claude_native_status_file.py`: `resolve_status_file`
(pid-first via the tmux pane pid, which equals Claude's pid on this
launch path; sessionId cross-check + freshness-bounded scan fallback),
`read_session_status` (busy/waiting -> running, idle -> idle), and a
`SessionStatusPoller` that lazily resolves then mtime-polls the cached
path and emits deduped status edges, deactivating when the file
vanishes on clean exit.
- terminal.py: add `pane_pid_sync()` and an `on_tick` hook so the poller
runs on the existing watcher cadence — no second thread.
- resource_registry.py: for the claude-native role only, build the poller
and drive it via `on_tick`; while it is active the PTY on_activity/
on_idle edges defer status to the file. The PTY watcher keeps owning
the activity badge and exit detection, and reclaims status if the file
never resolves or disappears.
waiting maps to running for now (no new status vocabulary); surfacing a
distinct "needs input" state is a possible fast-follow.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
Update the Discord-watch roster (rotation_roster.json), leaving 9 people in the rotation. Prune elapsed dates from the schedule and extend the
horizon through 2026-10-30 so every upcoming weekday is assigned to a
current roster member.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): tune sidebar vertical spacing rhythm
Refine the sidebar's padding and gaps so the primary nav reads as a
proper section and the row lists sit on a consistent rhythm:
- Primary nav (New session / Automations / Inbox): 8px gap to the
Omnigent header (pt-2), no bottom padding of its own (pb-0); the 16px
gap below now comes from the scrolling list (pt-4), matching the
section-to-section gap-4 rhythm.
- Nav rows and session rows are 32px tall (h-8) with 4px vertical
padding (py-1).
- Section headers (Pinned / Projects / Sessions) get 8px bottom
padding (pb-2).
- Session rows and project folder rows stack flush (gap-0).
- Bulk-action bar uses uniform 6px padding (p-1.5).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e-ui): regenerate visual baselines
* test(web): update sidebar spacing assertions to new rhythm
Bring the existing layout assertions in line with the tuned spacing:
primary nav pt-2/pb-0, nav + session rows h-8, iconless section header
pb-2.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e-ui): expect 32px session row height after spacing bump
Session rows moved from h-7 (28px) to h-8 (32px) in the sidebar
spacing tune; update the row-layout e2e assertion to match.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
When "Select sessions" is toggled on, the currently-viewed session's row
kept its active background even though it wasn't explicitly selected,
making the selection state ambiguous. Gate the active-route highlight on
`!selectionMode` so a row shows a background only when it's the active
session (normal mode) or explicitly checked (selection mode).
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): file new-in-project sessions under their project immediately
Stamp the omni_project label at session create so a session created from
the new-session composer is born filed under its project, instead of
flashing under the ungrouped "Sessions" section for a couple of seconds
until the follow-up project_id move catches up in the search-indexed
session list. The sidebar dual-reads project membership from the label
or the first-class project_id, so the row groups under its project from
its first appearance; the existing move then promotes it to project_id.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): cover born-filed new-session-in-project flow
Add a Playwright e2e that lands on the /?project=<name> composer and asserts
the create POST /v1/sessions carries the omni_project label, so a session
created inside a project is filed under it immediately (satisfies the
E2E UI Required coverage gate for this web behavior change).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* docs(web): correct the born-filed move-failure catch comment
If the project_id move fails, the session stays filed via its create-time
omni_project label (not unfiled) — fix the stale catch comment.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* perf(web): make add-to-project instant — optimistic move + slim PATCH
Moving a session into a project waited on resolve→PATCH→refetch, with
the PATCH shipping a ~415KB items snapshot, so the row sat in its old
section for seconds. Overlay the membership optimistically from the
cached project id, render folder bodies as the union of their own pages
and the loaded window (so the row lands in-folder in one frame), and
return the PATCH snapshot without items (~1KB).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore(server): regenerate openapi.json for the PATCH sessions docstring
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): keep folder-only rows visible through an optimistic move
A row loaded only via an expanded folder's own pagination has no copy in
the flat window for the folder union to re-home, so dropping it from its
source folder blanked it from the sidebar until the refetches landed.
Insert such rows into the target folder's cached page and skip the
removal when nothing else can show them. Adds a browser e2e covering the
sidebar move flow end-to-end.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Follow-up polish on the grouped/colored help (#3795). Focused on the
one-liner copy and a duplicate listing entry.
- Normalize harness short help to `Launch <Name> with Omnigent.` — was
an inconsistent mix of `Launch [the] <Name> [TUI] in an Omnigent
terminal`, and "in an Omnigent terminal" was noisy.
- Trim over-long / over-specific one-liners:
- `attach`: drop the "— never starts anything" clause (the body still
explains it's a pure client).
- `uninstall`: `Uninstall Omnigent from this machine.`
- `usage`: `Show your Omnigent usage and costs.` (was pinned to
today / 7 / 30 days).
- `upgrade`: `Upgrade Omnigent to the latest release.`
- `debug`: `Internal maintenance commands.`
- Hide the `update` alias (same Click object as `upgrade`) from the
listing via `_ALIAS_COMMANDS`, so it no longer shows as a duplicate
line; it stays registered and runnable.
- Update/extend tests for the new copy and the hidden `update` alias.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
Split the top-level `omnigent --help` command list into two sections —
`Harnesses` (agent/harness launch commands) and `Commands` (everything
else) — add brand-accent color, and hide harnesses whose optional extra
isn't installed (with a small notice pointing at `omnigent setup`).
- Add a `format_commands` override on `_OmnigentCLI` that partitions
visible subcommands using a `_HARNESS_COMMANDS` set, sharing one
aligned help column across both sections.
- Colorize headings (`Usage:`, `Options`, `Harnesses`, `Commands`) in
the brand accent, harness names in accent, other command names in
cyan, and option flags in green — via `format_usage`/`format_options`
overrides and a `_help_style` helper.
- Hide extras-gated harnesses (`cursor`, `antigravity`) from the listing
when their SDK isn't importable, via `_harness_extra_checks` (lazy
`find_spec` predicates). The commands stay runnable — running one
offers to install the extra. When any are hidden, show a dim notice
pointing at `omnigent setup` (which lists those harnesses and offers
the install), rather than enumerating extras that may change.
- Color is gated on `NO_COLOR` and Click strips ANSI on non-TTY sinks,
so piped/CI help stays plain. Alignment is ANSI-safe (Click's
`term_len` strips escapes before measuring columns).
- Add tests covering grouping, the extras-gated show/hide, and the notice.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A
## Summary
- Added `--extra`, `--target-version`, and `--dry-run` flags to `omni upgrade`.
- Upgrade commands for `uv tool` and `pipx` now read the originally requested extras from the installer's receipt/metadata and preserve them.
- Explicitly refuses auto-upgrade for `pip` and `uv pip` because those installers do not record requested extras, making a safe automatic upgrade impossible.
- Fixed installer metadata detection in `uv tool` installs by avoiding `Path.resolve()` on the `bin/python` symlink, which previously pointed to the shared uv interpreter and missed `uv-receipt.toml`.
- Added/updated unit and CLI tests covering the new behavior.
## Test Plan
- `uv run pytest tests/cli/test_upgrade_command.py tests/cli/test_update_check.py tests/cli/test_cli.py -q --timeout=60` → **383 passed**.
- `uv run pytest tests/cli/test_update_check.py -q --timeout=60` → **112 passed**.
- Manually built a local wheel, installed it as a `uv tool`, and verified dry-run output.
- Verified `--extra` unions with detected extras.
- Verified `uv pip` install is refused with a manual-upgrade message.
## Demo
N/A
## Type of change
- [x] Feature
- [ ] Bug fix
- [ ] 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
Manual verification was done by building local wheels and installing the current dev version as a `uv tool`:
```bash
# 1. Build wheels
rm -rf /tmp/omnibuild && mkdir -p /tmp/omnibuild
uv build --wheel -o /tmp/omnibuild .
uv build --wheel -o /tmp/omnibuild sdks/python-client
uv build --wheel -o /tmp/omnibuild sdks/ui
# 2. Install as uv tool with the "all" extra
rm -rf /tmp/omni-dev-test
UV_TOOL_DIR=/tmp/omni-dev-test uv tool install --find-links /tmp/omnibuild \
'/tmp/omnibuild/omnigent-0.8.0.dev0-py3-none-any.whl[all]' --force
# 3. Dry-run upgrade from outside the source repo
cd /tmp && /tmp/omni-dev-test/omnigent/bin/omni upgrade --dry-run --target-version 0.8.0
```
Output:
```text
Targeting v0.8.0.
Detected installer: uv
Detected extras: all
Would run: uv tool install --reinstall omnigent==0.8.0[all]
```
Adding `--extra server` unions with the detected extra:
```bash
cd /tmp && /tmp/omni-dev-test/omnigent/bin/omni upgrade --dry-run --target-version 0.8.0 --extra server
```
Output:
```text
Targeting v0.8.0.
Detected installer: uv
Detected extras: server
Would run: uv tool install --reinstall omnigent==0.8.0[all,server]
```
A `uv pip` install is correctly refused:
```text
omnigent was installed with `uv pip`, not `uv tool install`. `uv pip` does not record which extras were requested, so `omni upgrade` cannot preserve them safely. Upgrade manually:
uv pip install -U omnigent
# or, if you need extras:
uv pip install -U 'omnigent[your,extras,here]'
```
## Changelog
`omni upgrade` now preserves requested extras for `uv tool` and `pipx` installs, supports `--extra`, `--target-version`, and `--dry-run`, and refuses to auto-upgrade `pip` / `uv pip` installs because those installers don't record extras.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A
## Summary
- `nodeLinker: hoisted` was a compatibility shim from the npm→pnpm migration
that forced an npm-style flat `node_modules`. Removing it returns pnpm to its
default isolated/symlinked layout (packages under `node_modules/.pnpm/…`),
restoring strict dependency isolation — dependencies must be declared, so
phantom/undeclared deps stop resolving by accident.
- Validated that the blockers the shim was assumed to guard against don't
actually block under the isolated layout (details in Test Plan). The Shiki
cyclic-import crash is handled by the existing `manualChunks` guard in
`web/vite.config.ts` (a chunking concern, independent of the node linker), and
electron-builder v26 collects the production dependency tree correctly through
pnpm's symlinks.
## Test Plan
Validated locally under the isolated layout:
- `pnpm install --frozen-lockfile` — clean and lockfile-consistent (the linker
setting is not part of the lockfile, so no lockfile churn).
- `pnpm --filter web run build` — succeeds; Shiki resolves to a single acyclic
chunk via the existing `manualChunks` guard.
- Electron packaging: `pnpm --filter web run build:overlay` then
`electron-builder --dir` builds and signs the app; inspected the resulting
`app.asar` — it bundles exactly the production dep tree (`electron-updater`,
`js-yaml` + their 14 transitive deps) with zero dev-dependency bloat.
- Tailwind v4 `@source` scan follows the symlink: the emitted CSS is
byte-identical between the hoisted and isolated builds.
- oxlint (schema) and prettier run; `node web/node_modules/vite/bin/vite.js
--version` (Android Gradle entry) and `web/node_modules/.bin/tsc --version`
(iOS Fastlane probe) resolve via pnpm's direct-dependency symlinks.
Not runnable locally — relying on CI to confirm: Docker image build,
`electron-build` full installers, and `android-bundle` / iOS app builds.
## 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
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [x] Not applicable
## Coverage notes
Node-linker layout has no unit-test surface; verified manually via a full
install + web build + electron `--dir` packaging (inspecting the packaged
`app.asar` dependency tree) + a Tailwind CSS byte-diff, and confirmed the
hardcoded node_modules paths (vite entry, tsc/prettier/oxlint) resolve through
pnpm's direct-dependency symlinks. Remaining platform builds are covered by CI.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
N/A
- Remove the legacy `_LAUNCHERS` fallback from `__init__.py` — all providers
are now resolved exclusively through the `SandboxProviderRegistry`
contribution-based registry.
- Simplify `get_launcher()` to a single code path (no more
`DeprecationWarning` / legacy import fallback).
- Remove unused `warnings` / `importlib` / `importlib.util` imports from
`__init__.py`.
- Add `docs/extending/sandbox_providers.md` documenting how to implement and
register a third-party sandbox provider, including a minimal example
package with `pyproject.toml` entrypoint, the namespace requirement, and
the capability reference table.
```bash
uv run pytest tests/onboarding/sandboxes tests/sandbox tests/cli/test_cli.py tests/server -k managed -q
pre-commit run --files <changed files>
```
All 782 selected tests pass and pre-commit is clean.
N/A
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
Existing tests pass unchanged. The test that expected a `DeprecationWarning`
from the legacy path was updated to no longer suppress it. New docs are
prose-only and need no test coverage.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
## Related issue
N/A
## Summary
- The `manualChunks` guard that keeps Shiki in one chunk (to avoid a cyclic
import crash — the language-index ↔ alias-map split that throws "Cannot read
properties of undefined (reading 'flatMap')" and blanks the Monaco/file
viewer) matched both `/shiki` and `/@shikijs/`. That also swept every
`@shikijs/langs/<lang>` grammar — which Shiki loads via dynamic import as
per-language chunks — into the single, eagerly `modulepreload`ed core chunk.
So ~200 language grammars (~1.68 MB gzip) were downloaded on every initial
page load, even though a session uses only a few languages.
- Exclude `@shikijs/langs/<lang>` from the `shiki` chunk so grammars stay lazy
per-language chunks. Keep Shiki's core, engines, and bundle glue together so
the cyclic core stays intra-chunk — the engines must stay too: excluding them
re-splits the cycle across chunks and reintroduces the `flatMap` crash.
- Initial-load eager JS drops from ~11.8 MB to ~4.37 MB (Shiki 1.68 MB → 466 KB
gzip); grammars become 427 on-demand chunks. Layout-independent (same result
under pnpm hoisted and isolated).
## Test Plan
- `pnpm --filter web run build` succeeds.
- Verified the emitted `shiki` chunk statically imports only the rolldown
runtime (no cross-chunk cycle) and contains `bundledLanguagesAlias`
co-located with its reader — under both hoisted and isolated node_modules.
- Verified per-language grammar chunks (python, rust, typescript, …) are
emitted separately and are NOT `modulepreload`ed by `index.html`.
- Recommended pre-merge smoke test: open the file viewer / Monaco editor and a
markdown code block and confirm syntax highlighting renders.
## 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
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Verified via build output analysis: the `shiki` core chunk is acyclic with the
alias map co-located (cycle fix preserved), and language grammars are emitted as
separate, non-preloaded chunks. Existing Shiki/code-block tests exercise the
runtime highlighting path; this change only affects chunk grouping, not module
behavior.
## Changelog
Faster initial load — syntax-highlighting language grammars now load on demand instead of all upfront.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
Remove the release-specific Gemini fallback from the Antigravity SDK executor. Preserve explicit per-turn and HARNESS_ANTIGRAVITY_MODEL precedence, but omit LocalAgentConfig.model when neither is set so every supported google-antigravity 0.1.x release owns its current default for both API-key and Vertex sessions.
Expand the no-hardcoded-model scanner to recognize dotted, canonical, and normalized Gemini release ids. Add coverage that distinguishes an omitted SDK model from an explicit override, and remove the stale release example from runtime error text.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
The embed build set sourcemap: false, so downstream bundlers that embed this
output (e.g. the Databricks monolith's rspack/webpack) had no input map to
chain through — host-side error stack frames bottomed out at
omnigent-embed.js:<line> instead of the original src/**.
Emit maps so the embedding host can compose them to source. dist-embed is a
build artifact (gitignored), so this ships nothing new; it only enriches the
maps hosts consume via source-map-loader.
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
* feat(sandbox): scan write_paths for dotfiles too
The dotfile / escaping-symlink masker walked cwd and every read_paths
root but skipped write_paths, so a writable directory granted outside
cwd could still leak — and let the helper overwrite — top-level secrets
like .env / .aws / .ssh.
Fold read_paths and write_paths into one deduplicated, ancestor-first
set via a new merge_scan_roots helper so every granted root is masked,
and a path granted by more than one lever (or nested under another
grant) is walked once instead of once per lever. The dedup resolves
each root a single time and skips nested roots with a lexicographic
cover scan, so the big-grant profile-size guard stays fast.
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
* fix(sandbox): only drop nested grants when scanning recursively
Review caught that merge_scan_roots dropped a granted root whenever
another grant was its ancestor — but that subsumption only holds when
the walk is recursive. cwd_hidden_scan_recursive defaults to False,
where each walk masks only a root's immediate children, so dropping a
nested grant (e.g. write_paths: [/a/deep/nested] under read_paths:
[/a]) left its top-level dotfiles visible and writable — reintroducing
the exact leak this branch closes, and regressing the prior
per-read-root behavior.
Thread the recursive flag into merge_scan_roots: keep the cwd drop
(unchanged, pre-existing), but only collapse a grant into a kept
ancestor when recursive=True; in top-level-only mode keep every
distinct grant and drop only exact duplicates. Walk the full ancestor
chain (not just the last kept root) so an interleaving sibling name
cannot hide a real ancestor and leave a redundant walk.
Adds regression tests in both backends for the non-recursive nested
grant, plus merge_scan_roots unit coverage for both modes.
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
* fix(sandbox): exclude framework scratch roots from the write-root dotfile scan
Extending the dotfile mask scan to write_paths also swept in the
framework-added scratch tmpdir (folded into write_roots via
with_additional_write_roots). That dir holds the sandbox's own egress
relay socket `.egress.sock` — a dotfile — so the scan masked it with
`--bind-try /dev/null` (bwrap) / a deny rule (seatbelt), cutting the
relay endpoint and resetting every egress connection. This is what broke
the inner-rest `test_egress_e2e[linux_bwrap]` cases.
Track framework write roots on the policy as `mask_scan_skip_roots` and
drop them (and anything nested under them) from `merge_scan_roots`. These
dirs are created fresh by the framework and never hold pre-existing user
secrets, so scanning them is both pointless and harmful. Genuine
user-declared read/write grants are still scanned.
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
---------
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
* lint(models): remove the hardcode baseline
Delete the empty path/count allowlist and its parser, stale-count logic, tests, and special pre-commit trigger. The scanner now rejects every non-owned production model literal while retaining only the AST-verified StaticModelFallback boundary.
Update the migration plan to describe the final configuration/catalog/fallback state. The fully merged issue 3426 audit passes 136 focused tests, mypy, the hardcode scan, and full pre-commit.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* lint(models): scan every production model literal
Remove the model-context name heuristic so positional arguments, bare collections, and config values under arbitrary keys cannot bypass the hardcoded-model check. Preserve docstrings and structurally owned fallback records as explicit non-runtime exceptions, and distinguish complete model ids from stable family-prefix compatibility checks.
Curate the newly exposed production literals by resolving Claude's direct-login custom model through the central owned fallback and replacing release-specific CLI, Bedrock, and loader examples with provider-neutral guidance.
Validated with 172 lint/Claude tests, 63 loader tests, focused mypy, the baseline-free repository scan, and pre-commit run --all-files.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* lint(models): cover the full production tree
Run the hardcoded-model scanner across every tracked Python and supported config/shell file rather than a curated directory list. Exclude tests and generated OpenAPI explicitly, and keep the pre-commit trigger exactly aligned with the scanner surface.
Remove the unused root server config that pinned a stale Databricks model and profile. A repository-wide dry run found no other non-generated production literals outside the existing scan surface.
Validated with the focused lint suite, the baseline-free full repository scan, focused mypy, and pre-commit run --all-files.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): keep Claude custom fallback routable
Resolve the private Sonnet custom-picker id through the exact owned fallback when available, then through the first routable Sonnet-family entry if release naming drifts. Fail clearly when the owned subscription catalog contains no Sonnet entry instead of forwarding an invalid picker id.\n\nRemove the vestigial full-tree scan-root constant, keep the pre-commit parity probe direct, make the Gemini docstring fixture exercise a recognized id shape, and update the migration guide to describe the actual full-tree literal scan and runtime-prose expectations.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* ci(models): configure automation model roles
Replace provider release ids in credentialed workflows with six repository-variable roles covering Anthropic, fast Anthropic, OpenAI, E2E judge, E2E model pool, and image generation workloads.
Make the shared Omnigent agent action require an explicit model input, validate required configuration before writing provider files, and keep fail-open reviewer/image helpers on their existing degradation paths.
Use the protocol-level mock-model fixture for mock-only integration matrices and remove their unused production model-spread configuration.
Validation: parsed all action/workflow YAML; generated integration and backcompat matrices; hardcode lint and staged pre-commit passed.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* ci: fail fast without E2E judge model
Require the repository-level E2E judge model variable before running the required-check script. This turns an absent CI configuration into an immediate, actionable failure instead of allowing a later command to fail ambiguously.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* ci: clarify missing optional model variables
Keep the advisory reviewer ranker, VS Code changelog drafter, and feature-blog image generator fail-open when their repository model variables are empty.\n\nEmit actionable variable names before skipping or falling through to the existing warning path, avoiding malformed gateway requests while preserving the best-effort behavior of all three jobs.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Add a lint step that fails when a `pnpm-workspace.yaml` `overrides:` pin
doesn't satisfy the range the same package declares in a workspace
`package.json`. Overrides outrank package.json, so such a mismatch
silently ignores the declared version — the trap that let a postcss
security bump (`^8.5.18`) land while the override still pinned the
vulnerable `8.5.15`, invisible to both `--frozen-lockfile` and the
lockfile-regen gate (the lock was internally consistent for the pin).
Runs in the lint job beside the existing "Check pnpm-lock.yaml is up to
date" step. The checker uses a small npm-flavored semver comparison
(`^`, `~`, exact, comparators) over the operators this repo uses;
unrecognized ranges are reported rather than passed silently.
Co-authored-by: Isaac
* ci(release): add source-PR demo-video table to release-post PRs
The publish-changelog workflow reformats a published release into a site post
that leaves a `TODO` demo placeholder under each feature, with no pointer to
the source PRs that may already ship a recording. Parse the feature PR refs
from the curated release body (Major new features / Breaking changes sections;
bug fixes are dropped from the post, so from the table too), detect whether
each PR already has a demo video attached (same detection as feature-blog.yml
— uploaded asset links, bare .mp4/.mov/.webm/.m4v URLs, <video> tags; images
not counted), and inject a per-section PR | Title | Demo video? table into the
release-post PR body (and the dry-run preview) so reviewers can drop an
existing clip into a placeholder instead of re-recording.
Runs independent of the LLM reflow so it also helps the raw-body fallback;
best-effort (continue-on-error), leaving the table empty on failure.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* ci(release): group demo-video table by the post's curated features
The demo-video table grouped PRs by the raw release-body sections, so it listed
every feature PR (e.g. all 20 under "Major new features") even though the
published post is curated down to a handful of headline features, each with one
demo placeholder. Reviewers saw far more PRs than the post has slots for.
Have the release-post-formatter emit a RELEASE_POST_PRS map (feature title ->
contributing PR refs) after the post, and build the table from that so its
groups match the post's numbered features and only list the PRs behind them.
Validate the map against the harvested PR set. When no map is present (raw-body
fallback, where the post keeps every feature), fall back to grouping by the raw
release sections as before.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* ci(release): match feature-section headings at any level
The demo-table section parser matched only `## ` headings, but the release body
uses `### ` (h3) section headings, so it found zero feature sections and built
an empty table. Match `#{2,}` and test the heading TEXT with startswith, so
"Major new features" / "Breaking changes" match at any level while "Bug fixes
& hardening" and "Thanks to our community" are still excluded.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
The pnpm-workspace.yaml `overrides:` block force-pinned postcss to
8.5.15 and linkify-it to 5.0.1 — both flagged by open high-severity
advisories (postcss GHSA-r28c-9q8g-f849, linkify-it
GHSA-v245-v573-v5vm). Because the override sits above package.json, the
earlier dependabot bump of postcss to ^8.5.18 (#3385) was inert: the
lock kept resolving 8.5.15, so the CVE was never actually fixed, and the
frozen-lockfile gate saw no drift.
Bump the two override pins to the patched releases and regenerate the
lock (postcss 8.5.18, linkify-it 5.0.2). Both are same-minor patch
bumps confined to security/bug fixes — unlike the vite/tailwind/
lightningcss pins in the same block, they aren't the bundler, so they
don't affect chunk splitting or the Shiki/PDF-worker asset emission the
override comment warns about. CI's Docker build + web test validate the
bundle.
Co-authored-by: Isaac
* feat(models): persist last-known-good catalogs
Persist validated MLflow provider catalogs under the platform user-cache directory so catalog-backed defaults survive transient GitHub and release-CDN outages after one successful fetch.
Keep the existing one-hour freshness window, fall back to stale validated data for at most seven days after a live failure, and record cache schema, upstream schema, source URL, and fetch time. Atomic replacement keeps concurrent writers from exposing partial JSON, while corrupt, incompatible, wrong-source, and over-age entries fail closed.
Make OMNIGENT_DISABLE_CATALOG_LOOKUP bypass memory, disk, and network state for hermetic tests. Cover persistence, fresh reuse, stale provenance logging, corruption repair, schema/source rejection, over-age behavior, concurrent writes, and first-run failure.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): accept compatible catalog schemas
Validate the current MLflow catalog shape without coupling live discovery or persistent cache reuse to one exact minor schema string. Accept major-version-compatible string and integer forms, continue rejecting unsupported majors and malformed values, and document when stale in-memory fallbacks retry discovery.
Production release assets for Anthropic, OpenAI, Gemini, and OpenRouter were verified against the validator; focused catalog tests and full pre-commit pass.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): preserve cache across empty catalogs
Reject empty live catalog model maps so transient or truncated upstream payloads cannot overwrite useful last-known-good data. Tighten compatible schema parsing to ASCII digits so corrupt cache metadata is ignored instead of raising.
Percent-encode provider names in release asset URLs to keep path and query delimiters inert. Add regression coverage for empty-result fallback preservation, non-ASCII schema corruption, and URL construction.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* ci(oss): gate lockfile regen on a consistency check
The OSS lockfile-regen job deleted pnpm-lock.yaml and re-resolved from
scratch every 12h, so any in-range transitive drift on public npm
produced a ~1500-line churn PR that advanced ~20 unreviewed deps for no
functional reason (e.g. #3631). The job exists to keep the tree
Docker-buildable when a manifest change desyncs the lock — not to chase
newer upstream versions.
Add a check-first gate: `uv lock --check` and pnpm
`--frozen-lockfile --lockfile-only` verify each lock still satisfies its
manifests. These pass on a consistent-but-not-latest lock, so routine
drift no longer triggers a regen; only a real manifest/lock desync flips
`drifted=true` and runs the regenerate → Docker smoke → PR steps.
Also correct the PR-body text, which claimed it regenerated "uv.lock +
web/package-lock.json" (the repo locks pnpm-lock.yaml, not
package-lock.json).
Co-authored-by: Isaac
* ci(oss): keep the Docker smoke on the no-drift path
Per PR review: ungate the Docker build + CLI smoke so they run every 12h
regardless of drift. On the drifted path they still validate the freshly
regenerated locks before commit; on the clean path they remain the
ongoing proof that the committed locks + public registries build a
working image — catching buildability regressions independent of
manifest state (a yanked-but-in-range package, a Dockerfile break) that
the check-only gate would otherwise miss.
Co-authored-by: Isaac
* ci(oss): gate each ecosystem's regen on its own drift flag
Per PR review: a single shared `drifted` flag meant a desync in one
ecosystem (say uv.lock) still ran the `rm -f pnpm-lock.yaml &&
pnpm install` from-scratch regen of the other, re-resolving it against
public npm and reintroducing exactly the in-range transitive churn this
job is meant to avoid.
Split into `drifted_uv` / `drifted_pnpm` and gate each Regenerate step
on its own flag. A combined `drifted` (either) still drives the shared
token-mint and open-PR steps; the commit stages only whichever lockfile
actually changed.
Co-authored-by: Isaac
* feat(web): redesign sidebar bulk-selection bar and scope it per section
Rework the sidebar's bulk-selection UI into a single bordered "pill" bar
rendered directly under the header of the section it targets, and give
selection an explicit scope so it acts on the right rows.
- Bar redesign: one pill row with an Exit (X) button, an "N selected"
count at the session-title font size, and icon-only Archive + Delete
actions. Archive shows by default and is disabled until an archivable
session is selected (Delete likewise). Unarchive replaces Archive only
when the selection is entirely archived.
- Row checkbox moved to the left of the session title.
- Selection scope: the Sessions-header trigger selects the flat session
list; the Projects-header kebab's "Select sessions" selects the
sessions nested inside project folders (bar renders under the Projects
header). Entering a scope preserves current folder expansion. The
shift-select range and a stranding guard follow the active scope.
- Fold the Projects expand-all/collapse controls plus "Select sessions"
into a kebab to the right of the New-project (+) button.
Test-only: update unit tests for the new layout/scoping and rewrite the
e2e-ui bulk-actions suite (5 passing) to match the redesign, including a
projects-scope round-trip.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): resolve projects-scope selection against folders' own rows
Projects-scope bulk selection sourced its action set, shift-select range,
and stranding guard from the global paginated window
(sections.projectGroups), but each ProjectFolder renders from its own
independent useProjectSessions query. A folder member outside the global
window would toggle the count yet silently drop from bulk archive/delete,
break shift-select, or trip the stranding guard.
Each ProjectFolder now reports its rendered rows up via
onConversationsLoaded; the parent unions them (deduped) into a
projectSessionPool that backs the bulk-action bar, the shift-select range,
and the guard — so all three agree on what's selectable regardless of the
global pagination window.
Adds a regression test: with the folder query returning p1,p2,p3 while the
global window holds only p1,p2, shift-select p1->p3 spans all three and
bulk-archive fires with p3 included.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): surface owned Delete count and guard selection-mode against transient empties
Addresses two non-blocking review notes on the bulk-selection bar:
- Delete acts only on owned rows, so a mixed-ownership selection (reachable
in projects scope, where a folder can hold others' sessions) read
"N selected" while Delete hit fewer. The Delete control's label/tooltip
now shows the owned count ("Delete 2") when it differs from the selection
size. Archive needs no such hint (its enable-gate already forces a
uniform archive group, and archived rows never appear in a selectable
section).
- The stranding guard that exits selection mode when the pool empties now
skips while the sessions query is refetching, so a background refetch
that briefly yields an empty page can't kick the user out mid-task.
Adds a mixed-ownership Delete-label test and updates the layout spec's
label assertion.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* docs(web): clarify projects-scope stranding guard can't exit on a folder refetch
The exit-on-empty guard suppresses the global query's refetch via
conversationsQuery.isFetching, but the projects pool is fed by per-folder
queries too. Note that the pool unions global-derived membership, so a
single folder's transient-empty refetch can't zero it while any member is
in the global window — only a genuinely empty pool exits. Comment-only.
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
The feature-blog workflow leaves a `DEMO REQUIRED` marker in each drafted
post and tells the reviewer to record a demo, with no hint that the source
PRs may already ship one. Collect the contributing PRs per feature and detect
whether each already has a demo video attached (uploaded asset links, bare
.mp4/.mov/.webm/.m4v URLs, or <video> tags — images are not counted), then
inject a PR | Title | Demo video? table into the draft PR body so reviewers
can pull an existing recording into the marker instead of re-recording.
Reuses the gh pr view call already made to pick the reviewer (extended with
title/body/url). The table is written per feature even when no PR has a video.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* feat(web): rework workspace rail — tabs, shell-as-tab, maximize, + menu
Reworks the desktop right "Workspace" rail's tab strip so open items and
navigation read as one editor-style set, and gives shells a home inside the
rail instead of taking over the chat column.
- Reorder the strip: open file/shell tabs own the flexible left region; the
static nav tabs (Files/Agents/Shells/Tasks/Browser) sit right when tabs are
open, else stay anchored left.
- Shells open as top-strip tabs (desktop): clicking a shell row opens it as a
closable rail tab whose xterm renders in the rail's content slot — the chat
page is undisturbed. Mobile keeps the full-screen drawer.
- Add a full-screen (maximize) toggle pinned to the rightmost edge; maximized
keeps the docked card styling (same inset/height), only the width changes.
- Add a "+" menu ("Open new" → Shell) that trails the last tab when tabs are
open, else sits by the nav tabs. Browser stays a pinned tab (one embedded
WebContentsView per conversation).
- Tighten strip spacing and give the nav icons a consistent hover background;
smaller shell-tab label text.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): keep one ml-auto in the rail strip; drop phantom gap & maximize padding
Follow-up layout fixes to the workspace rail tab strip:
- Only ever one ml-auto in the strip row — two siblings both claiming it split
the free space and stranded the nav group mid-strip. With open tabs the
divider owns ml-auto (dragging nav + maximize right together); with no tabs
the maximize button owns it (nav group stays left).
- The divider dropped its ≥500px container-query gate so it shows at any rail
width instead of vanishing on a narrow rail.
- FileTabsStrip / TerminalTabsStrip return null when empty — an empty wrapper
still consumed a slot in the region's gap and left a phantom gap before the
trailing "+".
- Removed the maximize button's pl-0.5 so it sits flush like the other icons.
Adds regression tests asserting exactly one ml-auto per strip state, the
divider's presence/placement, the no-phantom-gap child count, and no maximize
padding.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* fix(web): flat tab hover background — opaque fill, no gradient patch
The tab hover used bg-muted, but --muted is a translucent token (6% black).
The close-button overlay then faded in a second translucent gradient on top,
stacking alpha on the right edge into a visible darker patch. Use the same
opaque color-mix selection surface the active tab uses for both the hover
background and the overlay gradient, so hover is a flat even fill.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e): update shell-open tests for rail-tab behavior
Shells now open as tabs in the workspace rail (xterm in the rail content
slot) instead of taking over the main column via MainTerminalView. Update
the three e2e tests that asserted the old main-column flow:
- shells/test_new_shell: assert the shell opens as a rail tab (Close
"zsh · u-…" x + rail-scoped xterm) with the chat surface undisturbed.
- files/test_right_panel: clicking a shell row opens a "zsh · main" rail
tab; xterm connects in the rail, chat not replaced.
- sessions/test_terminal_theme: resolve the connected xterm inside the
Workspace rail rather than main-terminal-view.
Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
* test(e2e-ui): regenerate visual baselines
* feat(web): shell-type picker, pinned rail tab strip, sidebar restore
Follow-ups to the workspace-rail rework:
- "+" menu Shell entry: clicking Shell launches the remembered default type
immediately (selection optional); the submenu check-marks and remembers the
last-picked type (persisted to localStorage), used as the next default.
- Removed the Shells tab's "+ New shell" row — shell creation now lives solely
in the "+" menu. The Shells tab is a pure list.
- Hide the Shells tab (and mobile entry) unless a shell actually exists; merely
declaring shell access no longer surfaces an empty tab.
- Tab strip: nav icons + divider stay pinned left and the "+" stays pinned right
at every rail width — the tabs region is the sole horizontal scroller, and the
"+" sits outside it (no scroll/overlap). Divider shows at all widths again.
- Full screen: collapse the left sidebar on enter and restore its prior state on
exit (collapsed stays collapsed, open reopens).
Updated unit + e2e tests to match (shell-open via the "+" menu; Shells-tab gate).
Co-authored-by: Isaac
* fix(web): keep "+ New shell" in the mobile Shells drawer
Removing the "+ New shell" row broke first-shell creation on mobile, which has
no tab-strip "+" menu. Restore it there only:
- InlineTerminalsSection gains an opt-in ``showNewShell`` prop (default off);
the desktop rail stays list-only, the mobile drawer passes it to surface the
create row.
- The mobile Shells menu entry gates on existing-shell OR declared shell access
(so the drawer is reachable at zero shells), while the desktop rail tab stays
gated on an existing shell.
- Update the two e2e tests that opened a shell via the removed row to use the
"+" menu; the mobile drawer test's docstring clarifies the mobile-only create
path.
Co-authored-by: Isaac
* test(e2e-ui): regenerate visual baselines
* fix(web): restore sidebar on session-switch un-maximize; detangle toggle
Addresses Polly review notes on the full-screen sidebar handling:
- The session-switch reset un-maximizes the rail directly, but didn't restore
the sidebar it collapsed on entry — so maximize → switch conversation left the
sidebar silently collapsed. Extract restoreSidebarAfterMaximize() and call it
from the reset (only when we were maximized).
- Move the sidebar side effect out of the setRightPanelMaximized updater into a
plain toggleRightPanelMaximized handler, so the state setter stays a pure
prev→next flip instead of nesting other setters.
Co-authored-by: Isaac
---------
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
* refactor(pi): discover inner gateway models live
Replace the seven-model Databricks registry embedded in the inner Pi executor with the workspace's Unity Catalog model-service listing.
Enrich live entries with MLflow context and output limits when available, while retaining the selected-model registration path so catalog outages do not prevent a configured session from launching.
Expose normalized max-output metadata, cover live routing and offline behavior, and ratchet all seven Pi entries out of the hardcode baseline.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(pi): normalize selected catalog aliases
Rewrite a live Unity Catalog alias to the exact configured Pi launch selector before rendering models.json. This keeps the menu deduplicated without dropping the concrete id Pi must resolve at startup.
Also document why explicit selections bypass picker compatibility filtering, remove a stale static-list reference, and make scalar metadata precedence explicit. Preserve live token metadata in the alias regression test.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
The first message is silently ignored (sandbox/lakebox wake) or
double-processed (managed relaunch) because of a race between the
server's persist-before-forward invariant and the runner's
crash-recovery turn detection.
When the server calls session-init (POST /runner/v1/sessions) immediately
before forwarding a message — managed sandbox wakes, sub-agent binding
repairs, host relaunches — the runner loads history during create_session.
Since the server already persisted the message to DB (invariant I1), the
runner sees it as a pending user message and starts a crash-recovery turn.
The subsequent message forward then arrives to an occupied _active_turns,
gets buffered, and is processed a second time once the recovery turn
finishes.
Add suppress_recovery_turn to the session-init envelope. The server sets
it True whenever it calls session-init as part of the message-forward
flow, so the runner skips recovery-turn detection and the forward is the
sole trigger for the turn.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* docs(models): remove stale runtime model examples
Describe Bedrock inference profiles, routing policy inputs, and child-session overrides in provider-neutral terms instead of recommending release-specific model ids in runtime help.
Ratchet the five corresponding hardcode-baseline entries and document that concrete examples belong in tests or provider-owned documentation, where they cannot become stale runtime guidance.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(models): retain Bedrock id shape guidance
Keep the setup prompt provider-neutral while showing the non-obvious inference-profile identifier shape. The hint uses placeholders instead of a release-specific model id, so it remains useful without becoming stale or expanding the hardcode baseline.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* Document provider-neutral model id shapes
Restore useful model-format guidance with synthetic, non-release examples in Bedrock setup, routing policy, and child-session help. Keep concrete release ids out of runtime text so examples teach syntax without becoming stale recommendations.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Add an `allow_destructive` parameter (default `False`) to the GitHub
policy that separately gates irreversible destructive operations
(deletes). Normal writes (create, update, push) are still governed
by `write_repos` / `write_branches`; destructive operations require
BOTH being in `write_repos` AND `allow_destructive=True`.
Destructive operations gated:
- MCP: delete_file, delete_branch, delete_release
- Shell git: git push --delete, git push origin :branch
- Shell gh: delete actions across 13 groups (repo, release, issue,
gist, cache, codespace, project, variable, ssh-key, gpg-key,
secret, label, run)
For MCP, the destructive check fires after the repo allowlist so a
destructive op on a non-allowed repo still gets the repo DENY. For
shell ops, the destructive DENY fires early since even an
undeterminable-repo destructive op should be DENY not ASK.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(sessions): reject undeclared sub_agent_name at create (#3526)
POST /v1/sessions persisted an arbitrary `sub_agent_name` with no check
that the parent's spec declares it. Every downstream site that swaps in
the resolved child spec is guarded by `if ... is not None` with no
`else`, so a name that resolves to nothing left the parent spec, workdir,
harness and instructions in place — silently booting the child as a full
clone of the parent (runaway recursion for an orchestrator), with nothing
logged and nothing failing.
Fail loud at the create route: `_require_declared_subagent` loads the
trusted parent bundle and rejects a name the spec does not declare with
404, before any row is persisted. This mirrors normal `sys_session_send`
dispatch and the AGENTSPEC.md contract that unlisted names are rejected.
The check only fires when the bundle loads and the name is positively
absent; a load failure or absent cache cannot prove the negative and is
left to fail-loud downstream.
Defense-in-depth: the four runner spec-swap sites now log a warning on a
resolve-miss (`_warn_unresolved_sub_agent`) so stale rows or post-create
bundle edits that still reach the fallback are diagnosable instead of
invisible.
Test: test_subagent_create_rejects_undeclared_name asserts the create
route 404s on an undeclared name.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* test: declare sub-agents in tests that create children (#3526)
The new create-time gate rejects a `sub_agent_name` the parent spec
doesn't declare, which broke existing tests that spawned children of a
sub-agent-less parent:
- test_sessions_endpoints.py: two external-status tests created a
`worker` child of the default (no-sub-agent) agent. `create_test_agent`
now takes `sub_agents`; both declare `worker`. `build_agent_bundle`
gives each bundled sub-agent a default `claude-sdk` harness (the strict
spec_version:1 parser requires one for an omnigent executor).
- e2e_ui/conftest.py: the `hello_world` fixture now declares a
`researcher` sub-agent inline, so the mobile-workflow and
subagent-tab-title fixtures can spawn a `researcher` child.
Full tests/server/integration/ suite passes (995 passed, 3 xfailed).
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
The changelog on main skipped from v0.5.0 to v0.7.0, missing both
released tags. Backfill v0.6.0 and v0.5.1 in version order, and remove
the orphaned [Unreleased] block (its two entries — the Nord theme #2561
and per-harness command overrides #2933 — are already covered by the
v0.6.0 section).
v0.6.0 entries are cleaned from the auto-drafted PR #2960: dropped
non-entries (placeholder "written by Isaac" lines, "DELETE THIS SECTION"
markers, N/A refactor/cleanup notes), de-duplicated entries already
recorded under v0.5.0 (#1835, #2371), and normalized doubled tag
prefixes. v0.5.1 is from PR #2395.
Supersedes and closes#1843, #1897, #2395, #2960.
Co-authored-by: Isaac
* fix(runner): evaluate PHASE_TOOL_CALL policy for sys_call_async background tasks
Out-of-turn sys_call_async dispatches run in a detached asyncio task after
the originating turn ends. The executor adapter's _stable_policy_evaluator
reads _current_ctx which is cleared to None by run_turn's finally block, so
PHASE_TOOL_CALL evaluations always fail closed to DENY regardless of the
configured policy.
Fix by evaluating PHASE_TOOL_CALL directly via the AP server's REST endpoint
before executing the background tool. This bypasses the SSE round-trip (which
requires a live turn stream) and instead calls POST /sessions/{id}/policies/evaluate
inline from _bg(). ASK is treated as DENY since there is no active turn to
surface an approval prompt.
Sessions without a server_client or conversation_id (e.g. tests) skip
evaluation, preserving existing behavior.
Fixes#3233.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(runner): pass arguments as dict in async PHASE_TOOL_CALL evaluation
The initial commit sent target_args (a JSON-encoded string) as the
arguments field. Every other PHASE_TOOL_CALL evaluation path sends a
dict, and the server's policy context builder + built-in safety policies
(e.g. argument-aware rules that inspect arguments.command) expect a dict.
Sending a string caused isinstance(args, dict) checks to fail silently,
so argument-scoped DENY/ASK policies couldn't inspect the async tool's
arguments.
Parse target_args into a dict before building the evaluation body, with
a fallback to {} for malformed input. Add a test assertion that verifies
the forwarded arguments are a dict with the correct contents.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* docs(runner): clarify ASK parking behavior in async policy evaluator docstring
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(claude-sdk): stop claiming live message queue support
ClaudeSDKExecutor.enqueue_session_message() called query() which queues
a new turn on the SDK's stdin rather than injecting into the active turn.
Returning True from this method caused the adapter to emit
injection.consumed, dropping the runner's buffered copy. The next user
message would then trigger a turn with an empty buffer, answering the
previous message — producing a permanent one-turn-behind desync.
Fix: return False from both enqueue_session_message and
supports_live_message_queue. The adapter's existing if-not-accepted
branch retains the message and delivers it as a normal continuation turn
once the active turn ends, preserving in-order delivery.
Closes#3472.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(lint): suppress ARG002 for unused-but-required override params
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(claude-sdk): send all batched steered messages, not just the last
When a user steers multiple messages during a running SDK turn, each is
buffered and the runner collapses them into one continuation turn whose
history ends in several consecutive user messages. On a resumed SDK
session _build_prompt called _extract_latest_user_content, which walks
history in reverse and returns only the FIRST user message it finds — so
the SDK saw just the last steered message and the earlier ones were
silently dropped (they remained in the transcript, making it look like
the second message was "ignored").
Add _extract_trailing_user_content: on resume, collect the whole trailing
run of consecutive user messages (those after the last assistant/tool
message) and concatenate them (blank-line joined for text; merged content
blocks when any message is multimodal). Prior turns stay SDK-cached and
are not replayed.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(models): resolve supervisor wizard defaults
Replace the legacy multi-agent supervisor wizard's OpenAI and Databricks model pins with provider-catalog suggestions while preserving the free-form model prompt.
Unknown custom endpoints now receive no unrelated vendor default and require an explicit model. Add endpoint-specific coverage and remove both wizard entries from the hardcoded-model baseline.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(onboarding): require a supervisor model
Reject empty supervisor model input before generating an openai-agents spec. Custom endpoints must now provide an explicit model, and known providers fall back to operator input if their catalog has no default.
Keep the user on the model-selection step with a clear validation message and cover both custom-endpoint and empty-catalog retries.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* docs(onboarding): map supervisor provider branches
Document how the helper's profile, default OpenAI, and custom-endpoint states correspond to the wizard menu. This makes the explicit-input fallback clear when future endpoint choices are added.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
#2976 moved git untracked-cache setup off the runner startup path into a
daemon thread. The worker now shells out to git at an arbitrary moment, so
it can land inside a test that has swapped the process-global
subprocess.run and be recorded as one of that test's own calls.
That is how it failed CI on an unrelated PR: the databricks login test
asserts on the argv it captured and instead saw a stray
`config core.untrackedCache true`.
Stub GitFilesystemRegistry.start for the suite by default, with an
untracked_cache_start fixture for the worker's own tests, and harden the
login recorder so foreign argv reaches the real runner rather than the
capture list.
Signed-off-by: Ross Sclafani <rsclafani@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 of the modular native-harness registry refactor landed (10 PRs,
2026-07-28 → 07-31). Bring the design doc in line with what actually shipped:
- Status header, Phase 1 subtotal, effort summary, and bottom line updated from
forward-looking ('1.1–1.3 in review') to Phase 1 complete / Phase 2 next.
- Ledger: 1.8 (#3648) landed; 1.4 marked descoped (with rationale); the 1.7
opencode-e2e follow-up (#3656) recorded; per-PR merge dates added.
- Calibration rewritten as a Phase 1 retrospective: estimate (~20–29 eng-days)
vs. actual (10 PRs / 4 calendar days), the real cost centers (test-shape churn
+ review-caught behavior bugs, enumerated per PR), the correct runner re-scope,
the two intentional behavior deltas (qwen label, antigravity relay), and the
recurring uv.lock / full-suite-only-flake operational friction.
Doc-only; no code change.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* feat(models): discover Cursor picker models from CLI
Replace the generated Cursor base-model catalog with live cursor-agent models discovery on the bound runner. Normalize compound effort variants and legacy dotted Claude spellings into the base-id namespace used by launch, /model switching, and terminal mirroring.
Route the discovered options through the existing session model-options cache, return retryable failures without blocking Cursor launch, and expose the same live listing to model-catalog callers. Remove the obsolete generator and eleven Cursor hardcode baseline entries.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): preserve valid Cursor picker options
Carry forward round-trip protection without restoring the stale generated denylist. Skip and log reversed Claude ids that the Cursor command parser cannot inject, while retaining CLI-advertised models verified against the current agent.
Keep cached picker options visible during asynchronous refreshes so model or effort changes do not transiently blank Cursor's picker. Cover both the parser guard and refresh behavior with regression tests.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): verify exact Cursor picker matches
Resolve the selected model's display name from the same live CLI catalog that supplies the Web picker, then refuse to press Enter unless Cursor highlights that exact row. This prevents fuzzy matching from silently selecting another model without restoring release-specific denylist entries.
Keep default/current tags unique in catalog order and limit stale-option retention to Cursor refreshes, preserving the existing drop-on-refresh behavior for Codex and other runner-backed pickers.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(models): centralize owned static fallbacks
Move the remaining Claude and Codex release-curated aliases into one fallback module. Record the responsible adapter, catalog provenance, and the concrete discovery gap for every registered fallback.
Carry that metadata through ModelListing and expose it in sys_list_models payloads, while preserving existing model ordering and provider behavior. Move—not expand—the nine lint baseline entries so future fallback edits remain explicit.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(cursor): reuse live model metadata when switching
Cache Cursor model display names when the runner serves the live picker catalog and pass the selected label into the TUI bridge. This avoids spawning cursor-agent models a second time for the same selection while retaining live discovery as a cold-cache fallback.
Also require the highlighted picker row to match the complete normalized display label or one of its suffixed variants, preventing similarly prefixed models from being accepted. Clear the cache with the existing session and agent lifecycle caches and cover cached, cold-cache, and fuzzy-match behavior.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(models): key fallbacks by provider constants
Use the canonical subscription and CLI-config kind constants as static fallback registry keys. This removes a silent coupling where renaming a provider kind could otherwise turn an owned fallback into an empty listing.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* lint(models): enforce owned fallback boundary
Allow unavoidable static model aliases only when AST analysis proves they are confined to complete StaticModelFallback records in the central model_fallbacks module. Require literal owner, provenance, and discovery-gap metadata, and reject fallback tuples reused outside those records.
Remove the nine centralized fallback rows from the count-based baseline while retaining the temporary baseline for independent migrations that have not landed yet. Add focused positive and bypass-resistance tests and document the structural exception.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(models): scan the owned fallback registry
Run the structural hardcode scanner against the production model_fallbacks module, proving the real stacked records satisfy the owned fallback boundary without count-based allowances.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(models): require the fallback registry
Make the production-registry lint assertion fail if model_fallbacks.py is missing instead of passing vacuously. Clarify that only module-level literal tuples qualify for the structural exemption so nested aliases intentionally fail closed.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* Update Codex fallback aliases
Replace the stale Codex fallback entries with the current GPT 5.6 Sol, Luna, and Terra aliases. Keep the change scoped to Codex; no Gemini fallback is introduced.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Creating a project against a container-deployed server failed with 405.
create_app mounts the projects router only when a project store is wired,
and the Docker entrypoint built every other store but never this one — so
POST /v1/projects was not a route at all and fell through to the SPA
catch-all (GET-only), which answers 405. The CLI server path already wires
it, so the same build worked under `omnigent server start` and failed in
the container.
Construct SqlAlchemyProjectStore from the resolved database URL and pass it
to create_app, mirroring the other stores.
Signed-off-by: apeltekci <andrew@peltekci.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Final Phase-1 PR of the modular native-harness registry refactor: move
registry-parallel enumerations onto HarnessCapabilities.
- Add a fork_history axis (ForkHistory enum: none/rebuild/preamble) to
HarnessCapabilities, declared per harness in _BUILTIN_CAPABILITIES. Derive the
server's two fork-history gating frozensets in _sessions/common.py from it
instead of hand-listing. The derivation emits each canonical id plus its
reversed native-<key> spelling, because native-claude/native-codex/native-cursor
are valid ids canonicalize_harness passes through unchanged and the read sites
match on the canonicalized id (guarded by the existing reversed-spelling fork
test) — so the derived sets are a superset of the prior literals.
- Add optional shell_tool_name / shell_tool_prompt fields carrying the harness
bench's shell-tool provocation; delete the bench's hardcoded
_NATIVE_TOOL_PROVOCATION table and read the fields off capabilities in
native_vendor() (byte-identical (tool_name, prompt) per harness).
- Delete the dead _HARNESS_MODULES literal in runtime/harnesses/__init__.py
(~120 lines, overwritten unconditionally by harness_modules() next line).
- Extend the drift-guard tests in test_harness_capabilities.py.
Scope kept tight to the doc's mandate: sets that would need new NativeCodingAgent
identity fields (_ANTIGRAVITY_FAMILY_HARNESSES, _PROVIDER_RESOLUTION_HARNESS,
*_NATIVE_TERMINAL_ROLE) are left as-is; noted as follow-ups.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* feat(models): discover Cursor picker models from CLI
Replace the generated Cursor base-model catalog with live cursor-agent models discovery on the bound runner. Normalize compound effort variants and legacy dotted Claude spellings into the base-id namespace used by launch, /model switching, and terminal mirroring.
Route the discovered options through the existing session model-options cache, return retryable failures without blocking Cursor launch, and expose the same live listing to model-catalog callers. Remove the obsolete generator and eleven Cursor hardcode baseline entries.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): preserve valid Cursor picker options
Carry forward round-trip protection without restoring the stale generated denylist. Skip and log reversed Claude ids that the Cursor command parser cannot inject, while retaining CLI-advertised models verified against the current agent.
Keep cached picker options visible during asynchronous refreshes so model or effort changes do not transiently blank Cursor's picker. Cover both the parser guard and refresh behavior with regression tests.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): verify exact Cursor picker matches
Resolve the selected model's display name from the same live CLI catalog that supplies the Web picker, then refuse to press Enter unless Cursor highlights that exact row. This prevents fuzzy matching from silently selecting another model without restoring release-specific denylist entries.
Keep default/current tags unique in catalog order and limit stale-option retention to Cursor refreshes, preserving the existing drop-on-refresh behavior for Codex and other runner-backed pickers.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(models): centralize owned static fallbacks
Move the remaining Claude and Codex release-curated aliases into one fallback module. Record the responsible adapter, catalog provenance, and the concrete discovery gap for every registered fallback.
Carry that metadata through ModelListing and expose it in sys_list_models payloads, while preserving existing model ordering and provider behavior. Move—not expand—the nine lint baseline entries so future fallback edits remain explicit.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(cursor): reuse live model metadata when switching
Cache Cursor model display names when the runner serves the live picker catalog and pass the selected label into the TUI bridge. This avoids spawning cursor-agent models a second time for the same selection while retaining live discovery as a cold-cache fallback.
Also require the highlighted picker row to match the complete normalized display label or one of its suffixed variants, preventing similarly prefixed models from being accepted. Clear the cache with the existing session and agent lifecycle caches and cover cached, cold-cache, and fuzzy-match behavior.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(models): key fallbacks by provider constants
Use the canonical subscription and CLI-config kind constants as static fallback registry keys. This removes a silent coupling where renaming a provider kind could otherwise turn an owned fallback into an empty listing.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* Update Codex fallback aliases
Replace the stale Codex fallback entries with the current GPT 5.6 Sol, Luna, and Terra aliases. Keep the change scoped to Codex; no Gemini fallback is introduced.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Follow-up to #3599 (PR 1.7). That PR moved the built-in native agent-name
constants into a shared public block in omnigent/native_coding_agents.py and
migrated the claude/codex host e2e tests onto them, but missed the opencode
sibling: test_host_opencode_native_e2e.py still defined a local
_OPENCODE_NATIVE_AGENT_NAME = "opencode-native-ui" literal and asserted a stale
'_ensure_default_opencode_agent did not run' message (that per-harness seeder
was collapsed into _ensure_default_native_agents).
Import the shared OPENCODE_NATIVE_AGENT_NAME constant and update the message so
all three host e2e tests are consistent. Test-only; opt-in e2e (skipped without
OMNIGENT_E2E_OPENCODE_NATIVE=1).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* refactor(harness): registry-driven server seeding loop (PR 1.7)
Collapse the server's built-in native-agent seeding onto the
NativeHarnessProvider seam. The 11 hand-written _ensure_default_<x>_agent
helpers + their 11 _build_<x>_native_bundle partners become two
registry-driven functions in omnigent/server/app.py:
- _build_native_bundle(provider): resolves provider.materialize_agent_spec via
the seam and runs the shared materialize -> bundle -> tar dance. The
per-harness `model` arg variance (codex required kw / kiro,opencode default /
the rest none) is bridged by one inspect.signature check.
- _ensure_default_native_agents(...): loops NATIVE_CODING_AGENTS, resolving the
provider by key and seeding each content-aware via _ensure_builtin_agent.
debby / polly / _ensure_extra_builtin_agents stay hand-written. Removed the now
-dead _<X>_NATIVE_AGENT_NAME constants and the *_NATIVE_CODING_AGENT imports.
Net server/app.py -455/+146.
Redeploy safety: builtin_agent_id(name) is a pure hash of the agent name, and
the names (NativeCodingAgent.agent_name) and bundle bytes are unchanged, so
seeded ids and bundles stay byte-identical (verified: sha256 of
_build_native_bundle output matches the pre-loop named builders across all
model-arg variants). New tests freeze the 11 expected ids and assert the loop
covers every native agent. Updated test_builtin_bundles / test_app to the
generic builder; fixed stale symbol refs in two e2e tests and a scheduled-tasks
integration test.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* test(server): cover the registry-driven native seeding paths (PR 1.7)
The seeding-loop collapse removed ~330 lines that were only exercised
transitively by e2e suites; add direct unit coverage so the new generic path is
fully covered and the coverage gate recovers:
- Parametrize the native bundle-builder tests over EVERY native agent (was a
4-agent sample), so each harness's _materialize_* + bundle path is covered
directly, across both model-arg shapes.
- Cover the two defensive guards in _build_native_bundle /
_ensure_default_native_agents (missing materialize hook, missing provider row).
- Add an end-to-end seed test asserting all 11 native agents register under
their stable builtin_agent_id with a retrievable bundle.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* docs(server): note the model-axis limit of the native seed signature bridge
Address Polly non-blocking note: the inspect.signature bridge in
_build_native_bundle understands only the `model` kwarg; a future harness
whose materializer needs a different required kwarg fails loud at seed time
rather than routing. Comment so the next author knows.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* refactor(harness): aggregate built-in native agent-name constants (PR 1.7)
The seeding-loop collapse deleted the 11 private _<X>_NATIVE_AGENT_NAME
constants from server/app.py (the loop uses agent.agent_name directly), which
pushed callers that need one specific built-in onto magic-string literals
("claude-native-ui", "qwen-native-ui", ...) in the tests.
Restore them as PUBLIC constants in omnigent/native_coding_agents.py — the
module that already indexes the registry rows — so seeding and tests share one
named, registry-derived source of truth instead of re-deriving the literal:
- Add CLAUDE_NATIVE_AGENT_NAME ... KIMI_NATIVE_AGENT_NAME (each = the row's
agent_name) to native_coding_agents.
- Point the server + scheduled-tasks tests at the shared constants (drop the
bare "qwen-native-ui" / "antigravity-native-ui" / "claude-native-ui" strings).
- Fold the two host e2e tests' own local _CLAUDE/_CODEX_NATIVE_AGENT_NAME
literals onto the shared constants too.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Two localized optimizations to SqlAlchemyConversationStore.list_items,
which backs GET /v1/sessions/{id}/items (the web chat transcript read).
- Scope the after/before cursor subqueries to conversation_id so they
land on the (workspace_id, conversation_id, id) primary key as point
lookups. Without it, (workspace_id, id) leads no index and each
paginated page degraded to a workspace-wide scan.
- load_only the seven columns _to_item reads, dropping the wide
search_text Text column that this read path never touches. On
Postgres search_text is TOAST-ed, so omitting it skips a detoast and
roughly halves the bytes pulled per row on a chatty conversation.
Scoping the cursor to the conversation also fixes a latent correctness
edge: a cursor id from another conversation previously resolved its
position workspace-wide and applied it as a cutoff; it now yields an
empty page, guarded by a new test.
Co-authored-by: Isaac
* fix(cli-auth): write the session-JWT file privately instead of chmod-ing it after
_store_entry's docstring already promised the file is written "with user-only
read/write permissions (0o600) - the file may hold session JWTs, which are
sensitive". The implementation did not deliver that:
path.parent.mkdir(parents=True, exist_ok=True)
...
path.write_text(json.dumps(data, indent=2))
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
write_text creates a missing file at the process umask, so on the very first
login - exactly when a session JWT is first persisted - the token sat on disk
readable by every local user until the chmod landed. Measured:
dir mode after mkdir : 0o755
file mode after write_text: 0o644 <- JWT is on disk at this mode
file mode after chmod : 0o600
The parent ~/.omnigent was also left world-traversable, and clear_token
rewrote the same file with no chmod of its own, relying on the mode of a file
it may not have created.
Routes both writers through _write_tokens_file, mirroring the pattern already
used in claude_native_bridge._atomic_write_user_json: a tempfile beside the
target (created owner-only by tempfile before any bytes are written), fsync,
chmod, then os.replace. The directory is created 0o700.
The rename also fixes a robustness bug: write_text truncated in place, so a
write that failed partway left a truncated file, and the JSONDecodeError
handler in _store_entry treats that as {} - silently discarding every stored
token for every server. The temp is discarded on failure and the previous file
is left intact.
Tests: tests/test_cli_auth_token_file_mode.py. Three of the eight fail on the
previous code (the on-disk window, the directory mode, and token loss on a
failed write); the rest pin the final mode, round-tripping, trailing-slash
normalisation and selective clearing.
Signed-off-by: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
* style: satisfy ruff format
Pre-commit's ruff-format hook flagged the skipif decorator in the new test
module; it fits on one line.
Signed-off-by: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
* refactor(cli-auth): hoist state-dir hardening into _write_tokens_file
Move the 0o700 mkdir + chmod from _store_entry into _write_tokens_file
so every writer routes through it. Previously only _store_entry
hardened the directory, so a clear_token-only interaction left a
pre-existing world-traversable (0o755) ~/.omnigent untightened. Adds a
regression test pinning that clear_token now hardens the dir.
Co-authored-by: Isaac
---------
Signed-off-by: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(models): discover Cursor picker models from CLI
Replace the generated Cursor base-model catalog with live cursor-agent models discovery on the bound runner. Normalize compound effort variants and legacy dotted Claude spellings into the base-id namespace used by launch, /model switching, and terminal mirroring.
Route the discovered options through the existing session model-options cache, return retryable failures without blocking Cursor launch, and expose the same live listing to model-catalog callers. Remove the obsolete generator and eleven Cursor hardcode baseline entries.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): preserve valid Cursor picker options
Carry forward round-trip protection without restoring the stale generated denylist. Skip and log reversed Claude ids that the Cursor command parser cannot inject, while retaining CLI-advertised models verified against the current agent.
Keep cached picker options visible during asynchronous refreshes so model or effort changes do not transiently blank Cursor's picker. Cover both the parser guard and refresh behavior with regression tests.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): verify exact Cursor picker matches
Resolve the selected model's display name from the same live CLI catalog that supplies the Web picker, then refuse to press Enter unless Cursor highlights that exact row. This prevents fuzzy matching from silently selecting another model without restoring release-specific denylist entries.
Keep default/current tags unique in catalog order and limit stale-option retention to Cursor refreshes, preserving the existing drop-on-refresh behavior for Codex and other runner-backed pickers.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(cursor): reuse live model metadata when switching
Cache Cursor model display names when the runner serves the live picker catalog and pass the selected label into the TUI bridge. This avoids spawning cursor-agent models a second time for the same selection while retaining live discovery as a cold-cache fallback.
Also require the highlighted picker row to match the complete normalized display label or one of its suffixed variants, preventing similarly prefixed models from being accepted. Clear the cache with the existing session and agent lifecycle caches and cover cached, cold-cache, and fuzzy-match behavior.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(cursor): drop unused parser binding
Keep the model-option setdefault call for deduplication without assigning its return value before the later result loop. This addresses the code-quality finding without changing parser behavior.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(cursor): handle missing CLI during model switch
Catch click.ClickException while refreshing a cold Cursor model catalog so a missing cursor-agent executable becomes the existing handled RuntimeError instead of escaping the runner endpoint as a 500.
Add bridge-level regression coverage for the preserved exception cause. The focused Cursor/native-event suite passes 122 tests and full pre-commit passes.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(telemetry): emit PolicyRegisteredEvent and PolicyDeletedEvent
Add two new telemetry events that fire on policy create/delete for
both session-level and admin-level policies:
- PolicyRegisteredEvent: fired after a successful POST to
/v1/sessions/{id}/policies or /v1/policies. Records handler,
policy_type, scope ("session" or "admin"), session_id, and
anon_user_id so we can see which handlers are being registered and
at what scope.
- PolicyDeletedEvent: fired after a successful DELETE. Looks up the
existing policy first so the handler is available; silently skips
emission when the policy was already absent (idempotent delete).
Both events follow the existing try/except BLE001 fire-and-forget
pattern used by SessionStoppedEvent and SessionDeletedEvent.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(policy-store): return deleted Policy from delete/delete_default
Previously delete() and delete_default() returned bool, causing a
second PK lookup in the route layer to retrieve the handler before
emitting telemetry. Changing the return type to Policy | None
eliminates that extra round-trip: the store already loads the row to
perform the delete, so we can return the entity at no additional cost.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* refactor(telemetry): drop handler from PolicyDeletedEvent, revert store changes
handler required a pre-fetch before delete to avoid an extra DB
round-trip, which meant changing the store layer. Dropping the field
keeps PolicyDeletedEvent simple and the store interface unchanged.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
The host orphan reaper's waitpid fallback uses os.WNOHANG and
os.waitpid(-1, ...), neither of which exists/works on native Windows.
Windows also has no child reparenting to a subreaper, so there is
nothing to reap. The periodic sweep swallowed the resulting
AttributeError, but the final drain in run()'s finally block runs
unguarded and would crash shutdown.
Return early with 0 when os.WNOHANG is absent, matching the reaper's
own "non-Linux is a no-op" contract.
Co-authored-by: Isaac
* refactor(harness): DI-seam runner interrupt/stop — collapse 16 handlers (PR 1.6)
Route the runner's native interrupt / stop dispatch through a
dependency-injected NativeInterruptRunner instead of 16 per-harness closures
plus two hardcoded `if _harness == "<x>-native"` chains in the /events handler.
Mirrors the CodexGoalRunner DI precedent (omnigent/runner/codex/goal.py):
app-scope state (AP client, resource registry, event publisher, sub-agent wake
plumbing, codex bridge-state resolver) is injected at construction, typed via
Protocol.
- New omnigent/runner/native/interrupt.py: the 9 uniform interrupt and 7
uniform stop handlers collapse to two descriptor-driven methods
(_UNIFORM_INTERRUPT / _UNIFORM_STOP); claude interrupt (bridge-id) and codex
interrupt (MCP-startup + turn/interrupt) keep dedicated methods, moved
verbatim. interrupt()/stop() return None for handler-less harnesses so the
caller falls through to the in-process cancel.
- app.py: the two dispatch chains become one runner.interrupt()/.stop() call +
fall-through; the 16 closures are deleted (net app.py -470). Local
`from omnigent.<x>_native_bridge import` stays at call time so bridge-module
monkeypatches keep resolving (no test repoints).
- 12 new unit tests for NativeInterruptRunner.
- Doc: add 1.6 ledger row (gap-fill deferred); flip stale 1.5c row to landed.
Scope: migration-only, behavior-preserving. The antigravity/opencode coverage
gap (no interrupt/stop handler; they fall through to _cancel_inprocess_turn) is
left unchanged and pinned by a no-handler test; wiring agy interrupt_turn() /
opencode client.abort() is a deferred follow-up.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* docs(runner): fix uniform interrupt/stop harness counts in interrupt.py
Address Polly non-blocking doc nit: the module comments said 'nine uniform
interrupt' and 'seven uniform stop', but _UNIFORM_INTERRUPT has seven entries
and _UNIFORM_STOP six (claude/codex interrupt and claude stop are special-cased;
codex/pi alias stop to interrupt). Clarify uniform-vs-total counts. Doc-only.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* refactor(pi): route wire APIs from catalog metadata
Replace Pi's release-specific GPT Chat Completions allowlist with normalized Unity Catalog model-service wire metadata shared by native and inner Pi execution.
Thread generic-provider wire configuration through the harness, resolve dedicated AI Gateway URLs back to their workspace API origin, and avoid probing non-Databricks providers. When discovery is unavailable, route unknown GPT models to Responses while retaining the documented system-model compatibility fallback.
Cover Chat, Responses, dedicated-gateway, generic-provider, alias, outage-cache, and Responses-only catalog behavior. Verified 275 focused Pi/catalog tests, isolated runtime spawn-env tests, live production UC metadata, and repository-wide pre-commit.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(pi): hoist model routing imports
Move the catalog, gateway, subprocess, and compatibility imports used by Pi routing to module scope so dependencies are explicit and consistently initialized.
Extract the shared Pi model compatibility predicates into a small leaf module to avoid introducing a model_catalog/pi_native_credentials import cycle. Update tests to patch the module-bound credential resolver.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* Add agy (Google Antigravity CLI) as a 7th polly sub-agent
polly's roster now includes agy alongside claude_code, codex, opencode,
cursor, hermes, and pi. agy drives the antigravity-native harness
(Gemini-native, own Google account auth via ~/.gemini; does not run
Claude/GPT-family models) and follows the same
IMPLEMENT/REVIEW/EXPLORE contract as the other worktree-scoped
implementers, with gate_pushes: false so it can open its own PRs.
Updates the roster count, preflight check, trigger phrases, and
cross-vendor review/cancellation lists in config.yaml; the
investigate/fanout/cross-review skills' vendor lists; and the
structural e2e test assertions (roster tuple, harness family map,
policy-argument count) to match.
Signed-off-by: Imraul Emmaka <ikemmaka@ualr.edu>
* fix(antigravity-native): re-deliver turns agy rejects while verifying the account
agy's TUI composer mounts ~3s after launch, but its account-eligibility
check is not settled until ~7-9s. A turn submitted inside that window is
consumed by agy — the draft leaves the composer, so the submit verifies —
and answered with "We're finishing verifying your account eligibility"
instead of starting a cascade. Nothing retried, so the turn was silently
lost and the terminal sat idle.
Detect the notice after a submit and re-deliver until agy takes the turn,
bounded by 90s. The running-turn marker is checked first so a notice still
rendered from a prior attempt can never re-send a turn that already landed,
and the probe fails open so a future agy that renames its running footer
keeps delivering rather than retrying.
Programmatic first turns — a polly sub-agent dispatch — land in that window
on every launch; interactive users usually type slowly enough to miss it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(antigravity-native): treat agy's collapsed-paste placeholder as a rendered draft
agy replaces a paste carrying many line breaks with a single
`[Pasted text #N +M lines]` row instead of echoing the text into the
composer. The threshold is line-count based (~13+ line breaks); total
length does not matter, so a long single-line message still renders
verbatim while a multi-line one never does.
The render gate looks for the message's needle in the composer, which a
collapsed paste can never contain, so delivery raised "agy did not render
the pasted message in its input box before submit" while the draft was in
fact sitting there. Sub-agent task prompts are exactly this shape, so a
polly dispatch failed on its first turn every time; the single-line
follow-up prompts it sent next happened to render verbatim and worked,
which made it look like a startup race.
Recognise the placeholder as draft content in _draft_in_input_region so
both the render gate and the submit verification key off it appearing and
then leaving the composer — the submit stays verified rather than blind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(antigravity-native): bind the TUI injector to an explicit bridge dir
The interaction bridge's default TUI injector resolved the bridge directory
from HARNESS_ANTIGRAVITY_NATIVE_BRIDGE_DIR, on the assumption (stated in its
docstring) that "the reader/CLI both run with it set". That is stale: the
reader now runs as a task INSIDE the runner process, which never carries that
variable — it is set only for the harness subprocess by
build_antigravity_native_spawn_env.
So every web approval failed with "HARNESS_ANTIGRAVITY_NATIVE_BRIDGE_DIR is
required" — 100% of the time, not intermittently. The RPC delivery flipped
agy's backend step, but agy's own permission prompt was never dismissed, so
the terminal did not advance and the next typed turn risked landing in the
stale prompt's buffer.
Add tui_injector_for(bridge_dir) and have the reader — which is handed its
bridge_dir — use it. _inject_via_tui stays for callers that genuinely run
with the harness env, with its constraint now spelled out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(antigravity-native): close a turn agy reports finished (quiescence backstop)
Turn completion was inferred purely by pattern-matching step types.
_is_turn_close_step has already accreted three special cases — clean text
close, ERROR planner, degenerate DONE — and its own docstring explains that
missing one leaves turn_active stuck True forever: the spinner never clears
and the NEXT turn cannot re-open RUNNING either. Every agy step type it does
not know about is a permanently stranded session, and that list only grows.
agy already publishes the answer. Every GetAllCascadeTrajectories summary
carries a per-cascade CASCADE_RUN_STATUS, which appeared in this codebase
exactly once — in a docstring example — and was never read, even though the
rotation detector already fetches those summaries on every scan.
Use it as a BACKSTOP: when agy reports the bound cascade idle on two
consecutive scans while Omnigent still believes a turn is open, close it. The
step-based close stays the fast path; this only catches what it missed. Being
reconciliation rather than edge detection, it is idempotent and self-healing —
a missed, unknown, or reordered step now costs one detector interval instead
of stranding the session.
Verified against agy 1.1.8 that the status reports RUNNING both while working
and for the entire time a permission gate is parked (75s observed), so the
backstop cannot close a turn that is waiting on a human. Two consecutive ticks
are required so the gap between delivering a turn and agy starting it is not
mistaken for the end of one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
* fix(antigravity-native): avoid duplicate verification retries
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Imraul Emmaka <ikemmaka@ualr.edu>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Imraul Emmaka <ikemmaka@ualr.edu>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(web): add zoom controls to subagent graph panel
Add zoom in/out and fit-to-view buttons to the subagent graph panel
using ReactFlow's useReactFlow hook. Widen the zoom range from
0.3–1.5x to 0.1–3x so users can zoom in closer to read small nodes
or zoom out further for large graphs.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* style(web): fix prettier formatting for zoom control buttons
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(openshell): pass workspace to SandboxClient lifecycle methods
The openshell SDK >=0.0.86 added a required `workspace` keyword argument
to `SandboxClient.create()`, `get()`, `delete()`, and `wait_ready()`.
Omnigent never passed it, so `sandbox create --provider openshell`
crashed with `TypeError: SandboxClient.create() missing 1 required
keyword-only argument: 'workspace'`.
Thread a workspace through _OpenShellClient and OpenShellSandboxLauncher,
resolved from: explicit constructor arg (YAML `sandbox.openshell.workspace`),
then `$OMNIGENT_OPENSHELL_WORKSPACE` env var, then "default".
Fixes#3513
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix: bump openshell floor to >=0.0.88 and close test gaps
The `workspace` kwarg landed in openshell 0.0.88, not 0.0.86 — 0.0.86
still has the old signature and would crash with `got an unexpected
keyword argument 'workspace'`. Bump the floor accordingly.
Also record the workspace reaching the fake SDK and assert it in both
the _OpenShellClient and managed_hosts tests.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* chore: strip index-dependent size fields from uv.lock
pypi.org's index serves wheel/sdist sizes while proxy indexes may not,
so re-locks were flipping ~2,900 'size = N' lines back and forth. The
sizeless form is canonical on main; this keeps the diff to the real
dependency changes.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
* fix(web): keep the session config gear usable while the session is asleep
The gear required liveness === "online", so an asleep session couldn't
change model/effort even though PATCH /v1/sessions persists overrides
and the next wake applies them. Gate the gear like the composer (inert
only for read-only viewers and unreachable sessions) and make the
native model catalog survive runner death so the picker stays filled:
- relay exit / refresh_state with no runner now mark the per-session
catalog stale instead of deleting it; snapshots keep serving it
- a stale catalog is re-fetched in the background once a live runner
is bound again, and replaced on success
- an asleep claude-native session with a cold cache (server restart)
refills from its host over the host tunnel - the same pre-launch
source the new-session picker uses
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(e2e): scope the mermaid preview assertion to the diagram svg
The rendered Streamdown mermaid block carries chrome icon svgs (zoom /
copy controls) next to the diagram, so the strict single-svg locator
fails with "resolved to 3 elements" on every run since #3498 merged.
Target the diagram svg via mermaid's aria-roledescription stamp, which
also makes the assertion check the diagram itself rather than any svg.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Follow-up to the non-blocking review notes on #3479.
- codex_executor consumes agent_env.declared_passthrough instead of keeping
its own copy. It already imports agent_env, so the reason the duplicate
existed no longer applies. Test repointed at the shared helper.
- POLICIES.md now explains that agent CLIs get a deny-by-default environment
and what env_passthrough is for. The migration note only ever lived in a PR
description, so the two cases that bite -- a generic ACP agent with no vendor
family, and a goose authenticated by an ambient provider key rather than
gateway routing -- were undocumented.
Signed-off-by: Mr-Neutr0n <harikp2002@gmail.com>
pypi.org's simple index serves a size for every file while proxy
indexes may not, so each re-lock added or stripped 'size = N' across
~2,900 lines depending on which index resolved it. Make the sizeless
form canonical (the hash is the integrity check): the fixer now drops
size fields and --check flags them, so re-locks from either side
converge on one form.
Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
ReactFlow's pan-on-drag behavior was intercepting pointer events on
graph nodes, preventing the existing <Link> wrapper from navigating.
Adding the `nopan nodrag` utility classes tells ReactFlow to leave
those events alone so clicks reach the router link.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
Mirror the pvc_mounts config knob for Kubernetes Secrets: project a
pre-created Secret as a read-only file volume on the runner's host
container. A Secret volume (no subPath) is refreshed in place by the
kubelet, so a long-lived runner picks up a rotated credential without a
restart — unlike envFrom, which is frozen at container start.
- server: parse/validate sandbox.kubernetes.secret_mounts at config load
(DNS-1123 name, absolute/normalized/non-reserved path, intra-list and
pvc<->secret path-collision checks), failing loud at startup
- onboarding: add the secret volume + host-container-only volumeMount in
build_pod_manifest (optional=False, defaultMode 0440), threaded through
the launcher
- tests mirror the pvc_mounts coverage
Signed-off-by: bdchatham <bdchatham@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Each open session in the web UI holds a long-lived event-stream HTTP
response. Over HTTP/1.1 browsers cap concurrent connections at ~6 per
origin, so opening several windows/tabs against a raw :8000 deploy fills
the pool with held-open streams and every other request stalls — the UI
appears frozen across all windows while the server is idle.
The bundled Caddy overlay and every managed platform already terminate
TLS with HTTP/2, which multiplexes the streams and dissolves the cap;
the gap was only that nothing told operators this proxy is also the fix.
Document it in the deploy README ("Serving") and point to it from the
Caddyfile. Docs-only; no server behavior change.
Co-authored-by: Isaac
* feat(sandbox): make recursive dotfile hiding opt-in, add mask_paths
The sandbox hid every dotfile under the working directory by walking the
whole tree. On medium-to-large projects that walk is slow and routinely
trips the entry cap, and it masks far more than the secrets it targets.
Make the recursive scan opt-in and add a way to hide specific paths:
- cwd_hidden_scan_recursive (default false) scans only the top level of
the cwd and each read_paths root (including $HOME when it is a granted
read path). The top-level dotfiles that hold most secrets (.git, .env,
.aws, .ssh, ...) are still masked, but the walker no longer descends the
whole tree. Set it true for untrusted trees where a deeply nested
credential file would be an unacceptable leak.
- mask_paths hides a named file or folder regardless of a leading dot,
resolved like read_paths (~ expanded, relative to cwd, no $VAR). Files
are masked as an empty file, folders as an empty view, on top of the
dotfile mask in every mode.
Both backends enforce the new fields: linux_bwrap binds /dev/null for
files and a tmpfs for folders; darwin_seatbelt emits literal/subpath deny
rules. Behavior change: with the non-recursive default, dotfiles nested
below the first level are now readable unless recursion is turned on.
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
* docs(sandbox): note is_dir symlink behavior for mask_paths
Clarify that the explicit mask_paths classification uses is_dir(), which
follows symlinks — unlike the dotfile walker's follow_symlinks=False — and
that seatbelt emits a harmless literal deny for a missing entry where bwrap
drops it on the re-stat.
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
---------
Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
* perf(web): render conversations before the full history window loads
Opening /c/<id> blocked first paint on fetchInitialHistoryWindow, which
pages backward (up to MAX_INITIAL_PAGES serial round-trips) until the last
two user prompts are on screen. On a real deployment each page is ~1s, so a
long tool-heavy last turn could stall the transcript for several seconds.
Fetch only the first page in the blocking bind, render immediately, then
page the rest of the window in the background behind a top-of-history
spinner. The previous-prompt heuristic is unchanged — just no longer on the
critical path.
- Extract the window-complete boundary into initialWindowComplete() and
reuse it in both fetchInitialHistoryWindow and the new backfill.
- bindStream fetches one page; backfillInitialWindow continues the same
paging loop after commit, holding loadingMoreHistory so scroll-up/rail
loaders don't double-fetch, generation-guarded like loadMoreHistory.
- New loadingInitialWindow flag drives a "Loading earlier messages…"
spinner above the oldest bubble.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* ⚡ perf(web): Unify initial history loading
- Build the prompt-boundary and viewport-fill window through one post-render loader
- Make the turn rail lazy and remove its eager 200-item history fetch
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* ✅ test(web): Cover lazy history loading
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* perf(web): pin the latest turn to the top with a trailing spacer
Add a LatestTurnSpacer as the last child of the message flow that pins the
newest turn's anchor to the top of the viewport (the newest real user prompt,
or the newest assistant text output when a page deep in a tool chain has no
prompt yet), letting the reply grow below it — the ChatGPT/Claude "question at
top" feel.
As a side effect the spacer keeps the transcript taller than its scroll
container whenever content sits above the anchor, so older history stays
reachable by scroll-up. That makes HistoryAutoLoader's viewport-fill fetch loop
redundant: it now pages only to the previous-prompt boundary (still capped by
initialWindowComplete), and the resize-driven re-fill and spinner-height
measurement are removed.
Spacer height = clientHeight − (anchor→content-bottom) − top gap, clamped to
≥ 0: it shrinks as the reply streams (its own top is fixed by the content
above, not by its height, so scrollHeight stays constant and stick-to-bottom
keeps the anchor pinned) and collapses to 0 once the reply exceeds the
viewport, restoring normal bottom-following.
Co-authored-by: Isaac
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* test(e2e-ui): regenerate visual baselines
* fix(web): keep loading history near the top
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* test(e2e-ui): regenerate visual baselines
* fix(web): preload history sooner near the top
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* refactor(web): show history skeleton for every page
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): stabilize scroll during history prepends
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* style(web): loosen history skeleton spacing
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* style(web): use compact history loading indicator
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): avoid latest turn spacer flicker
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): observe initial history scroll adjustment
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* fix(web): bind history loading to live scroller
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
* 🐛 fix(web): freeze spacer to loaded turn
---------
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
Replace the stale exact Qwen context-window registry with metadata from the shared MLflow provider catalog. Keep only the self-describing Anthropic [1m] marker and the conservative 128K offline fallback.
Reuse the onboarding catalog cache for both context sizing and pricing, preserve cache pricing fields in ModelInfo, and support provider-qualified ids, OpenRouter vendor namespaces, and Databricks aliases without release-specific model mappings.
Ratchet the hardcoded-model baseline and document the migration behavior. Cover exact, family, namespace, ambiguity, cache, encoded-metadata, and offline resolution paths.
Tests: 59 focused provider/context-window tests; 110 model-catalog, compaction, and session-override tests; changed-file pre-commit; repository-wide pre-commit except the pre-existing stale routing_pb2.py binding; live MLflow lookup smoke test.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Collapse the terminal-ensure / attach path in create_session_terminal —
11 hardcoded `if terminal_name == "<x>" and session_key == "main"` arms —
behind a single generic `_ensure_native_terminal(...)` shell dispatched
through the NativeHarnessProvider seam. The attach-path sibling of the 1.5b
launch shell (#3500/#3501); reuses the `_launch_<x>` adapters and
NativeLaunchContext. codex/antigravity supply an ownership predicate; codex
supplies a `finalize` for its one-shot policy notice — both run under the
per-session ensure lock, matching the inline arms.
- New shell in runner/native/orchestration.py (view-based existence check,
returns JSONResponse: 200 / 500 / 409), exported from runner/native.
- app.py: 11 arms (~450 lines) -> one collect-then-dispatch block.
- Repoint the HTTP attach-path claude/codex auto_create monkeypatch targets
to the orchestration module (the seam resolves the adapter there).
- 8 new unit tests for the shell.
- Doc: add 1.5c ledger row; flip stale 1.5b-i/ii rows to landed.
Behavior-preserving: qwen error label -> "Qwen Code" (display_name, as 1.5b-i);
antigravity now wires ensure_comment_relay via the base ctx (the landed
_launch_antigravity adapter already passed it).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Normalize Databricks Unity Catalog supported_api_types into the provider-neutral ModelWireAPI vocabulary and retain those facts while converting runner catalogs into the id-only routing-client shape.
Replace the exact Pi model exclusion table with a catalog-backed Claude wire check. Pi now keeps Responses-capable GPT models on its supported Responses path, while endpoints explicitly lacking Anthropic Messages are redirected to claude-sdk. Missing metadata from older runners remains unknown and does not trigger a redirect.
Ratchet six retired hardcode allowances and update the migration plan.
Tests: 104 catalog and smart-routing tests; 7 Pi Responses/provider tests; changed-file pre-commit suite. The repository-wide pre-commit run passed every relevant hook and only reported the pre-existing stale routing_pb2.py baseline.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Remove the release-specific model from the Kimi launcher example so an unoverridden session uses the default already configured in the Kimi CLI.
Document the ownership boundary, assert that the spawn environment omits HARNESS_KIMI_MODEL when no model is declared, and ratchet the retired lint allowance.
Tests: 12 Kimi spawn-environment tests; structural example load; staged pre-commit including YAML and hardcoded-model checks.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(models): discover ad-hoc CLI default
Resolve the model for minimal harness-less agent YAMLs from the Databricks OpenAI-family catalog during bundle materialization instead of baking a release-specific endpoint into the CLI.
Preserve --model and OMNIGENT_MODEL precedence, and fail with explicit configuration guidance when discovery is unavailable. Ratchet the removed pin from the hardcoded-model allowlist and document the new behavior.
Tests: 111 discovery-disabled CLI tests; staged pre-commit including the hardcoded-model lint.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(models): pin YAML model precedence
Add a direct regression test showing that an executor.model declared in agent YAML remains authoritative over OMNIGENT_MODEL and catalog discovery.
Fail the test if catalog resolution runs, so future changes cannot silently turn an explicit YAML model into a fallback lookup.
Tests: 114 CLI chat tests; targeted pre-commit checks.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(models): discover onboarding defaults
Select provider setup defaults from the live catalog after filtering specialty modalities, using stable family preferences for broadly accessible Anthropic and OpenRouter choices instead of release-specific model pins.
When discovery is unavailable, leave onboarding unpinned so the user supplies an explicit model. Add deterministic catalog fixtures for interactive CLI coverage, ratchet three lint allowances, and document the migration.
Tests: 147 onboarding and configure-models tests; staged pre-commit including the hardcoded-model lint.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): require intended OpenRouter family
Keep OpenRouter onboarding defaults within the catalog's Kimi family. If discovery returns no compatible family member, require the user to enter a gateway model instead of silently selecting a newer proprietary entry.
Correct the setup comments to match Click's prompt behavior: blank input accepts a discovered default, while an unavailable default requires an explicit value.
Tests: 86 provider and resolver tests passed. Targeted pre-commit passed for all modified files.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(models): pin offline runtime failure
Cover Anthropic and OpenAI runtime fallback when neither the agent nor provider config names a model and catalog discovery returns no data. Both paths must fail closed with guidance to configure an explicit model or retry discovery.
Document that removing source pins affects shared runtime defaults in addition to onboarding prompts, and clarify that required-family policy tokens use case-insensitive substring matching.
Tests: 88 focused runtime, provider, and resolver tests passed. A broader 153-test run reached 152 passes plus one unrelated host-credential leak in the existing Claude fallback test. Targeted pre-commit passed.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test: make sandbox-cwd assertions portable across macOS firmlinks
_resolve_sandbox_cwd ends in Path.resolve(), and macOS routes the test's
literal paths through firmlinks (/home via the automounter, /tmp ->
/private/tmp), so the literal-string assertions fail on any macOS dev
box while Linux CI stays green. Compare against the same resolution
instead; on Linux both sides are identical strings.
Signed-off-by: webdevtodayjason <jason@webdevtoday.com>
* test: tidy sandbox cwd portability assertions
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: webdevtodayjason <jason@webdevtoday.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(models): discover ad-hoc CLI default
Resolve the model for minimal harness-less agent YAMLs from the Databricks OpenAI-family catalog during bundle materialization instead of baking a release-specific endpoint into the CLI.
Preserve --model and OMNIGENT_MODEL precedence, and fail with explicit configuration guidance when discovery is unavailable. Ratchet the removed pin from the hardcoded-model allowlist and document the new behavior.
Tests: 111 discovery-disabled CLI tests; staged pre-commit including the hardcoded-model lint.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(models): pin YAML model precedence
Add a direct regression test showing that an executor.model declared in agent YAML remains authoritative over OMNIGENT_MODEL and catalog discovery.
Fail the test if catalog resolution runs, so future changes cannot silently turn an explicit YAML model into a fallback lookup.
Tests: 114 CLI chat tests; targeted pre-commit checks.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(e2e): pin sessions mock model
Give the sessions-default REPL fixture an explicit mock-server model so the test exercises session routing rather than ad-hoc model discovery.\n\nThe E2E workflow intentionally disables catalog lookup. After ad-hoc defaults moved to catalog resolution, the model-less fixture exited before the REPL opened. Other approval fixtures in this file already pin the same mock-compatible model.\n\nTest: OMNIGENT_DISABLE_CATALOG_LOOKUP=1 OMNIGENT_SKIP_WEB_UI=true uv run --frozen pytest -q tests/e2e/test_repl_sessions_approval_e2e.py::test_sessions_default_flag_works --tb=short\nTest: pre-commit run --files tests/e2e/test_repl_sessions_approval_e2e.py
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Second half of the runner launch seam, completing 1.5b. Routes the 3 special
arms and the turn-path opencode cold-boot through the seam, and consolidates all
11 create-session legs into one dispatch. Behavior-preserving.
- orchestration: extend the shell _launch_native_terminal with pre_launch (an
async (has_terminal) -> PreLaunchResult callback run inside the lock, so the
has_terminal-dependent rebuild/transfer/needs checks see the same state the
inline arms did), build_context (lazy full-context enrichment for claude's
bundle_dir/agent_name/skills + closures and codex's bundle, run only on
create), and reraise (turn-path opencode converts a launch failure to a 503
instead of publishing a start-error event).
- app.py: replace the 11 per-harness create-session legs with a single
collect-then-dispatch block — each leg only assigns its lock dict, context,
and optional pre_launch/build_context/resolve_agent_spec, then one
_launch_native_terminal call runs them. The 3 special arms (claude rebuild+
transfer, codex needs-check, antigravity payload+transfer) supply their
has_terminal-gated pre_launch; claude/codex supply build_context (codex keeps
the outer spec_entry as agent_spec). Turn-path opencode uses reraise=True.
- Preserve terminal_ready: only claude populated it in the create-session
response, so only claude's dispatch result is captured back (the consolidation
fixes a regression where 1.5b-ii's first cut dropped it).
- Tests: repoint the app-level _auto_create_<x>_terminal monkeypatches that now
route through the seam — claude create-session (events_lifecycle 603/688,
session_resources 2198) and the create-session auto-create guard tests
(terminals_autocreate: claude + antigravity) — to the orchestration symbol the
adapter calls. Add shell unit coverage for build_context (enrich-only-on-create)
and reraise. The terminal-attach/route patches (1.5c path) are untouched.
Net app.py reduction continues; the 11-arm launch chain is gone. Pre-existing
codex gateway-env failures in events_lifecycle are unchanged (codex arm behavior
preserved; those tests are unrelated app-server/gateway artifacts).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
The seven native resolvers (pi, hermes, kimi, cursor, goose, kiro, qwen) looked up their CLI with a bare shutil.which, while readiness and the SDK executors resolve through resolve_cli_binary's fallback ladder (the nvm/npm/homebrew bin dirs the daemon's frozen PATH omits). A CLI installed only in a ladder dir passes the readiness badge but fails at launch. Route the resolvers through resolve_cli_binary so the badge and the launch agree.
resolve_cli_binary gains a `which` hook so the resolvers keep their existing test seam; the fallback ladder always uses the real filesystem.
Signed-off-by: Andrew Demczuk <andrew.demczuk@gmail.com>
* fix(inner): stop agent-CLI subprocesses inheriting unrelated host secrets
Closes#3445.
pi and codex filtered os.environ before spawning their vendor CLI; goose,
kimi, qwen, acp and hermes did not, so every host secret - cloud tokens, other
providers' API keys - reached those processes, sandboxed or not. hermes was
worst: the no-HERMES_HOME branch passed env=None, which inherits everything.
Implements the decision on the issue.
agent_env.clean_agent_env(allow_prefixes, allow_exact, deny_exact,
extra_allowed, source)
The model is not "no credentials ever". It is a shared safe base (HOME, PATH,
proxy, locale, tmp, XDG, the omnigent-session marker), plus the harness's own
config/provider families, plus whatever the spec declared in
os_env.sandbox.env_passthrough.
Per-harness families, matching the table on the issue:
qwen QWEN_, OPENAI_, DASHSCOPE_
goose GOOSE_
kimi KIMI_, MOONSHOT_ (keeps its documented ambient auth)
acp none - base + env_passthrough only, the agent is arbitrary
hermes HERMES_ (see below)
pi and codex become thin calls. Their sets are preserved exactly, including
codex's OPENAI_API_KEY deny; verified by diffing the new output against the
original inlined logic over a synthetic environment - identical, with and
without passthrough. USER/LOGNAME/SHELL/TZ stay per-harness rather than
entering the shared base, because pi passes them and codex does not and this
refactor must not widen codex's set.
hermes prefix family: HERMES_ only, and deliberately not DATABRICKS_. Hermes
authenticates from files, not the environment - hermes_native_bridge copies
~/.hermes/auth.json and ~/.hermes/.env into the per-session HERMES_HOME
(hermes_native_bridge.py:386-394). HOME still passes, so nothing breaks, and
the credential family this change exists to contain stays contained.
Also restores the launcher's env-prune defense: the sandboxed paths bake
tuple(env.keys()) into with_spawn_env_allowlist, so a full-environ env made
that allowlist a no-op.
Tests: tests/test_agent_spawn_env_canary.py - parametrized over all seven
harnesses, planting nine credential-family canaries and asserting none
survive, plus that each still gets a usable environment, that a harness sees
its own family and not a sibling's, that kimi keeps ambient KIMI_/MOONSHOT_,
that env_passthrough works as the migration path, and that deny_exact beats a
matching prefix. 21 cases.
Executor suites: 967 passed, 13 skipped, 0 failed.
Signed-off-by: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
* fix(inner): point the spawn-env canary at the real executors
Addresses review on #3479.
- Extract _build_spawn_env() on qwen/goose/acp/hermes, matching kimi's
existing shape, and parametrize the canary over the real builders with
secrets planted in a monkeypatched environ. The prefix table was a hand
copy, so a harness reverting to os.environ.copy() kept the suite green;
it now fails, which is what the module docstring already claimed.
- Add NODE_EXTRA_CA_CERTS to BASE_ALLOW_EXACT. Node honours it where
SSL_CERT_FILE is ignored, so without it a corporate-CA user upgrading
loses TLS on every Node harness without a NODE_ family of its own.
- Warn in acp_executor._ensure_initialized when the handshake fails or the
child dies first, naming os_env.sandbox.env_passthrough as the likely fix.
Signed-off-by: Mr-Neutr0n <harikp2002@gmail.com>
---------
Signed-off-by: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
Signed-off-by: Mr-Neutr0n <harikp2002@gmail.com>
The policy evaluate endpoint is a BLOCKING hook: a harness waits on its
allow/deny before running a tool. Its payload rules were applied from a chain
of conditionals, and a rule in a branch only ever reaches whichever phase
lands in that branch. Three rules now come from one per-phase schema, and all
three run for every phase.
What was getting through:
- `event.data` was accepted as an object, a string or absent, then normalized
with `or {}`. For a tool phase that means the gate evaluated as though the
caller had sent nothing: every tool-name-scoped policy skipped, and the hook
answering allow. Tool and LLM phases now require an object; a bare string is
a legitimate wire form only on the prompt phase, and an absent payload is
malformed everywhere, since every first-party producer sends one.
- A tool-scoped gate needs a tool name, and only one spelling was accepted.
Producers differ: claude-native and the in-process tool dispatch send
`request_data.name`, the OpenCode plugin sends the tool in `event.target`.
Requiring the first rejected the second with a 400 — and that plugin turns
any non-2xx into ALLOW, so a stricter guard silently disabled every OpenCode
TOOL_RESULT policy rather than tightening it. Any declared source now
satisfies the rule, and the resolved name is written onto the container the
engine reads, so those policies gate instead of merely passing validation.
- `event.context` must be an object when present. An earlier revision of this
message described only two rules while the diff carried three.
`event.type` is also checked before being used as a dict key: an unhashable
value raised inside the lookup and surfaced as a 500 rather than a 400.
The three rules above were previously three independent structures (which
wire types are accepted; which phases need an object payload; where a tool
name may come from), each keyed by phase and each read with a permissive
`.get(phase, default)` fallback. The comment on them already said "one schema
per phase" — the code didn't enforce it: a phase added to the first structure
alone was silently accepted, validated as loosely as possible, and given no
tool-name rule at all, because the other two structures simply had no entry
for it and their lookups defaulted rather than erred. They're now one
NamedTuple per wire type with no default values on any field, so a new entry
cannot be added without deciding both properties at once, and the only
`.get()` left is the outer wire-type lookup, which 400s on a miss instead of
falling back to anything.
The test table enumerates each phase and non-object-data vector and
cross-multiplies them, rather than hand-listing every case — kept in sync
with the production schema by hand, since that schema lives inside a
route-registration closure and isn't something a test module can import. It
asserts the structured error code rather than the status alone, and now
includes a non-empty list alongside the empty one: both are simply
non-dict, but hand-listing only the empty list is coincidentally falsy in a
way a narrower, wrong fix (special-casing falsy values) would have passed.
Five mutations kill it: accepting object-or-string-or-absent everywhere,
requiring a single tool-name spelling, dropping the context rule, validating
the alternative spelling without normalizing it (caught because the oracle
asserts a tool-scoped DENY, not a 200), and giving one phase's schema entry a
wrongly permissive `data_must_be_object`.
A pre-existing test's docstring also claimed OpenCode's plugin sends REQUEST
data as a bare string; it now sends `{"text": ...}` like every other
first-party producer. Reworded to describe why the bare-string form is still
accepted (older/third-party compatibility) without attributing it to
OpenCode's current behaviour.
Signed-off-by: Andrew Reid <andrew@reid.ee>
Unconditionally set CLAUDE_CODE_USE_GATEWAY=1 in the Databricks ucode
subprocess env and stop setting CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS on
that path. Gateway-aware mode keeps tool search on so MCP schemas load on
demand, so the betas-disable knob is no longer needed here.
Update test_ucode_config_for_profile_reads_allowlisted_claude_state to
expect CLAUDE_CODE_USE_GATEWAY=1 in the ucode env instead of the removed
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS flag.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Harry Yao <harry.yao@databricks.com>
* fix(tunnel): give host/runner websocket tunnels a verifying SSL context
On interpreters whose OpenSSL default cert path is uninitialized (python.org
macOS framework builds before Install Certificates.command, and
python-build-standalone interpreters used by uv), ssl.create_default_context()
loads zero trust roots, so the host and runner wss:// tunnels failed with
CERTIFICATE_VERIFY_FAILED and looped on reconnect.
Add omnigent/tls.py (resolve_ca_file + cached client_ssl_context) that resolves
a CA bundle OS-trust-store-first with a certifi fallback, and pass that context
to both tunnel websockets.connect calls for wss:// (ws:// stays ssl=None).
egress/ca.py:_system_ca_bundle now shares resolve_ca_file; certifi is promoted
to an explicit dependency.
Closes#1730
Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
* fix(claude-native): pass a verifying SSL context to wss:// terminal-attach
_websocket_connect opened wss:// terminal-attach connections (the scheme
terminal_attach_url produces from an https workspace base_url) with a bare
default SSL context, so claude-native attach to a remote workspace hit the same
empty-trust-store failure fixed for the tunnels. Route it through
client_ssl_context() for wss:// (ws:// stays ssl=None). Also realign a
ws_tunnel test with the databricks_request_headers rename from main.
Closes#1730
Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
* chore(deps): record certifi in uv.lock
pyproject.toml promoted certifi to an explicit dependency; add it to the
omnigent package's dependencies and requires-dist in uv.lock so
"uv sync --locked" passes in CI. certifi was already resolved transitively,
so its package entry (with hashes) is unchanged — this only records the
direct dependency edge.
Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
---------
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
Session-discovered agents start with harness=null (filled lazily on hover
via prefetchAvailableAgentDetails). The fork picker filters candidates with
forkTargetCarriesHistory(a.harness), which returns false for null, so
custom agents were silently excluded from the fork agent dropdown even
though they appear fine in the new-session picker.
Fix: call prefetchAvailableAgentDetails for all agents when ForkSessionForm
mounts (same pattern NewChatDialog uses on dropdown open). The helper is a
no-op for agents whose harness is already known, so re-running on agents
list change is safe.
Adds a test that verifies prefetch is called for a session-discovered agent
(harness=null, sessionId set) on mount.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(web): group archived sessions by date
The archived sessions list in the settings page was a flat
chronological list that became hard to scan. Group sessions under
date headers (Today, Yesterday, Previous 7 days, Previous 30 days,
or month/year for older entries) for easier browsing.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(web): use DST-safe date arithmetic and add grouping tests
Use calendar-based setDate() instead of fixed millisecond offsets for
computing date boundaries in the archived sessions grouping, avoiding
mis-bucketing around DST transitions. Add a Vitest test with a pinned
system clock that verifies all five date group headers render correctly.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(web): share now across grouping, fix test locale/timezone flakiness
- Capture a single `now` in the groupedArchived memo and pass it to
every dateGroupLabel call, avoiding redundant Date construction and
a rare date-rollover inconsistency during iteration.
- Use local-time Date constructors in the test so bucket boundaries
match dateGroupLabel's local-time arithmetic in any timezone.
- Derive the expected month/year label via toLocaleDateString so the
assertion passes under non-English locales.
- Wrap assertions in try/finally so fake timers are always restored.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* feat(policy): add detect_loop builtin to catch agent retry loops
The #1 token-waste pattern is an agent retrying the exact same failing
tool call. max_tool_calls_per_session counts total calls but cannot
detect repeated ones. detect_loop tracks recent (tool_name, args_hash)
tuples in session_state and ASKs when the same call repeats N times
within a configurable sliding window, letting the user break the loop.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* address review: use full SHA-256 digest, add e2e tests
- Remove [:16] truncation from _args_hash to use the full 64-char
hex digest, avoiding false-positive collisions from 64-bit space.
- Add YAML → PolicyEngine e2e tests exercising the full roundtrip:
repeated calls trigger ASK, diverse calls pass, window eviction
works, and non-tool_call phases are unaffected.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* address review: guard params, fix docstring, move e2e test
- Clamp window and threshold to minimum 1 so zero/negative values
cannot cause unbounded state growth or always-ASK behavior.
- Add minimum: 1 constraints to both params in the registry schema.
- Fix docstring to describe actual persisted state shape (list of
SHA-256 hex digests, not tuples).
- Move e2e test from tests/runtime/policies/ to tests/e2e/ per
repo convention.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* feat(policies): add detect_thrashing builtin context policy
Agents that hit repeated tool errors burn tokens without making
progress. Add a new builtin contextual policy that tracks
tool-result outcomes in a rolling window and fires when the agent
appears stuck — either via consecutive errors or a high error rate
within the window.
Two independent triggers (both configurable, both independently
disableable):
- consecutive_threshold (default 5): fires after N straight errors
- window_error_rate (default 0.8): fires when ≥80% of the last
N results (window, default 10) are errors
Error detection is heuristic (common prefixes like "Error:",
"Traceback", "Permission denied", "fatal:", and JSON {"error": ...}
payloads). No server LLM required, unlike detect_task_switch.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(policies): address review feedback for detect_thrashing
- Fix docstring: "exceeds" → "reaches or exceeds" to match the >= check
- Rename misleading test names (test_below_consecutive_threshold_allows
was actually at-threshold; test_window_rate_allows_below_threshold was
at-threshold)
- Retain max(window, consecutive_threshold) history entries so the
consecutive check still works when window < consecutive_threshold
- Rate check now computes over the last `window` entries (not the full
retained history), and reports window size in the reason message
- Add integration test exercising state accumulation across evaluate
calls through the real policy engine
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(policies): harden detect_thrashing against edge cases
- Validate session_state history as list[int] before use; reset to
empty on corruption instead of raising TypeError.
- Guard against window=0 by using effective_window = max(window, 1)
to prevent division by zero in the rate check.
- Use dataclasses.replace in the integration test to preserve all
original RuntimeCaps fields instead of reconstructing with only
execution_timeout.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(policies): add minimum/maximum constraints to detect_thrashing schema
Add validation bounds to the registry params_schema so invalid config
values fail fast: consecutive_threshold >= 0, window >= 1,
window_error_rate in [0.0, 1.0].
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(policies): use Phase enum in detect_thrashing integration test
Use Phase.TOOL_RESULT instead of the bare string "tool_result" in the
PhaseSelector construction, consistent with other integration tests.
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
---------
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
* fix(web): clear deleted pinned sessions from the sidebar's Pinned section
The Pinned section reads a sibling ["pinned-conversations"] cache that the
delete mutations' prefix-matched ["conversations"] sweep deliberately skips
(nesting it under that prefix breaks the pin-toggle's cache patch). That
isolation is by design, but it means the delete handlers must drop the row
from the pinned cache explicitly — which they didn't. So deleting a pinned
session removed it from the flat list but left it lingering in the Pinned
section until a full reload.
Mirror the unpin removal in all three delete paths (single-delete onSuccess,
bulk-delete onSuccess, and bulk-delete onError's partial-success branch),
patching the pinned cache in place rather than invalidating for the same
search-reindex-lag reason the list is patched in place.
Co-authored-by: Isaac
* fix(web): keep the sidebar row height stable during delete
The in-flight "Deleting…" status row that replaces an interactive
conversation row used `text-sm py-2` with no height constraint, while
the interactive row uses `sidebar-compact-text h-7 py-0.5`. So starting
a delete didn't just recolor the row — it grew taller and changed font
size, shifting the surrounding list.
Match the deleting row's box metrics to the interactive row (h-7,
sidebar-compact-text font size, otto-sm radius) so the swap only changes
color/opacity.
Co-authored-by: Isaac
* fix(web): keep the sidebar row size stable when editing the title
The inline rename row rendered a `text-sm` (14px) input inside a wrapper
whose `py-1` + `size-7` buttons summed to ~36px, while the interactive
row is `h-7` (28px) with the 13px `sidebar-compact-text` font. So
double-clicking to rename made the row grow taller and bump the font
size, an input visibly larger than the row it replaced.
Match the edit row's box metrics to the interactive row (h-7,
sidebar-compact-text, otto-sm radius) and drop the buttons to icon-xs
(24px) so they sit inside the 28px row, leaving only the muted edit
background to signal the mode.
Co-authored-by: Isaac
* test(e2e): guard pinned-session delete clears the Pinned section
Adds a browser e2e that pins a session (while sitting on `/`, so it isn't
the active chat) and deletes it, asserting the "Pinned" section unmounts
in place — no reload.
Two harness details are load-bearing, and getting them wrong yields a
test that passes even against the buggy build:
- Delete a NON-active pinned session (page on `/`). Deleting the open
session navigates away and refetches; an active session also gets a
WS `removed`-frame reconcile. Either clears the row regardless of the
cache bug.
- Assert the "Pinned" SECTION disappears, not the row's href. While the
delete is in flight the row swaps to a hrefless "Deleting…" status row,
so an href-count assertion flickers to 0 during that transient and
passes spuriously; the section stays mounted until the pinned cache is
actually empty.
Verified it fails (~3s) against a build with the pinned-cache delete
patch removed, and passes with it.
Co-authored-by: Isaac
Collapsed tool runs in the chat transcript now read like the native
CLIs' step summaries ("Ran 1 shell command, read 2 files", "Listed 1
directory") instead of the generic "See N steps". The label is derived
from the folded calls' tool names and arguments in formatToolRunLabel:
- categories: shell / list / read / edit / search, covering omnigent
sys_* tools plus the native harness names (Claude Code Bash/Read/...,
Codex shell/apply_patch, pi & opencode lowercase bash/read/edit/...)
- shell commands that are a bare ls / cat recategorize as directory
listings / file reads, matching the vendor TUIs; codex's login-shell
wrapper (/bin/bash -lc '...') is unwrapped first
- runs of only unrecognized tools fall back to "Called N tools"
- per-step titles added for the native harness tools (Bash prefers the
model-written description, codex shell shows the unwrapped command)
The fold now labels only its own (hidden) contents; the whole-run
count plumbing is gone since the label no longer double-counts the
visible streaming tail.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): show startup spinner when a send relaunches a disconnected runner
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): show a sidebar starting spinner while a session is booting
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): don't rename the wrong session when the sidebar reorders mid-double-click
Double-click rename fired on whichever row received the dblclick event.
Browsers pair the two clicks of a double-click by pointer position and
timing, not element identity, so when the list reordered between the
clicks (an updated_at bump pushing rows around under the cursor) the
second click and dblclick landed on the row that slid into place and
opened rename on it — committing the typed title to a session the user
never aimed at.
Track the last two clicks each row receives and enter rename only when
the row saw both clicks of the pair; a dblclick preceded by a single
recent click means the double-click started on a different row and is
ignored.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): freeze sidebar order under the pointer so single-click actions hit the aimed row
The double-click guard can't help single-event interactions: a right-click
(or kebab click) that lands just after a background updated_at bump opens
the context menu of whichever row slid under the cursor — the menus are
visually identical, so the user renames (or archives, deletes, stops) a
session they never aimed at.
Fix it upstream of any one interaction: while the pointer is inside the
conversation list, pin every row's sort key at its first-seen value so
rows cannot move under the cursor at all. Keys accumulate lazily in
sortByUpdatedAtDesc (covering project folders and pages loaded mid-hover)
and clear when the pointer leaves, snapping the order back to reality.
The active row's frozen key captures its ActiveChatOverride value so
dropping the override mid-hover (clicking another row) can't move it
between the clicks of a double-click either.
Also rebuild the element tree per rerenderSidebar call in the row-actions
test harness — re-rendering the identical element let React bail out
without re-invoking the sidebar, silently ignoring mid-test data swaps.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): hold sidebar order while a rename edit is open, not just while hovered
The order freeze keyed off pointer position alone, but the pointer
naturally drifts out of the sidebar while typing a new title — the hold
released mid-edit and background updated_at churn resumed shuffling rows
around the open input. Moving the edit row's DOM node also blurs the
input, committing a half-typed title.
Rows now report an in-progress inline rename through RowEditHoldContext,
and ConversationList keeps the sort-key freeze active while the pointer
is inside the list OR any rename edit is open. The frozen-key map clears
only once neither hold remains, so the order snaps back on commit/cancel
(or pointer-leave with no edit open).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): engage the rename-edit order hold before paint
A passive effect reports the hold after paint, leaving a one-frame
window — when rename starts with the pointer already outside the list
(context-menu portal) — where a background updated_at reorder could
move and blur the just-mounted input. useLayoutEffect closes the gap.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): make trackpad wheel scrolling work in the terminal view
xterm's built-in wheel-to-mouse-report conversion damps sub-50px pixel
deltas by 0.3x and emits at most one report per DOM event, so macOS
trackpad scrolling over a mouse-tracking TUI (Claude Code, tmux mouse on)
barely moves. Replace it with a custom wheel handler that accumulates
deltas at face value and emits one SGR report per whole line, deferring
to xterm's native handling when the pane program isn't tracking the
mouse (e.g. a plain shell on the control transport).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(terminals): replay pane screen/input modes in the control-mode attach seed
capture-pane records cell contents only, so a TUI that entered the
alternate screen and enabled mouse tracking before the web client
attached (OpenCode, vim — anything that sets modes once at startup)
left the browser xterm believing no tracking was active: wheel events
sent nothing and the terminal view could not scroll until the program
happened to re-toggle its modes. Reconstruct the modes from tmux's pane
flags and replay them around the seed — alt screen before the content
so it never pollutes primary scrollback, mouse tracking/encoding and
DECCKM after the cursor restore.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): pin wheel-to-SGR-report forwarding for mouse-tracking shells
A program in a user shell enables any-motion + SGR mouse tracking and
records its stdin; a slow trackpad-sized wheel gesture over the xterm
must land >=3 wheel-up reports. xterm's damped built-in conversion
yields <=1, so this fails without the accumulating wheel handler
(verified against an unfixed UI build).
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(terminals): harden seed metadata parsing and quote e2e log path
Address review: pad missing/empty tmux mode-flag fields so a flags
anomaly costs only the optional mode replay, never the cursor and
alt-screen state; quote the wheel-log path typed into the e2e shell.
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e_ui): type-annotate the wheel test's tmp_path fixture
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(web): preserve file browser scroll position across session switches
The Files panel's scroll container never tracked its position, so
switching conversations collapsed the list to a loading state and
clamped scrollTop back to 0 with nothing to restore it.
Cache scrollTop per conversation (and per Changed/All view) in a
module-level map — the same pattern FolderTree uses for expanded
paths — restoring it once the view's data is ready, and gating saves
on having restored first so the loading-state clamp can't overwrite
the cached value.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): survive the loading clamp when restoring file browser scroll
The first cut restored scrollTop once when isLoading turned false — but
the files queries are disabled (not loading) until the environment query
resolves, so the restore fired against the short placeholder, clamped to
0, and the clamp's scroll event overwrote the cached position.
Gate on data presence instead, re-assert the target via an
animation-frame loop until the container can hold it (or its height
stops changing), and keep saving off until the restore settles.
Also re-sync FolderTree's expanded-paths state from its cache when the
conversation changes without a remount — previously the tree kept the
prior conversation's expanded set, which also skewed content height at
restore time.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): keep the open file's scroll position across session switches
The app remembers which file is open per session and re-opens it in the
viewer on switch-back — at the top. The earlier fix only covered the
Files panel list, so what users actually saw (the open file's content)
still reset.
Extract the clamp-surviving restore logic into a shared useScrollRestore
hook (FilesPanel now consumes it) and wire persistence into every viewer
surface, keyed per conversation + path: the Monaco code editor and diff
viewer (via their scroll APIs), the FileViewer content area, the
markdown/notebook previews, and the TipTap markdown editor.
Verified end-to-end in a real browser: Playwright tests scroll, switch
sessions via the sidebar, switch back, and assert the offset returns —
for both the file tree and an open markdown file.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
* fix(web): harden scroll restore against async content growth and Monaco clamps
The restore loop gave up as soon as the container's height held still
for one frame — but previews grow in bursts (async syntax highlighting,
image decode, lazy notebook cells), so a single stall stranded the
reader at the top. Replace the giveup with a 1.5s deadline that keeps
re-asserting the saved offset, and settle immediately on wheel/touch/
pointer input so the user is never fought for the scrollbar.
The Monaco surfaces saved onDidScrollChange offsets unconditionally, so
a not-yet-laid-out editor's clamp-to-0 event could permanently overwrite
the cached position. A shared attachEditorScrollRestore helper now
suppresses saves and re-asserts the target until it's reached, the user
scrolls, or the budget expires — the same contract as the DOM hook.
Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
---------
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
Lint / Version lockstep check (push) Has been cancelled
Sync OpenAPI to site / Open sync PR on omnigent-site (push) Has been cancelled
UI Preview / notify (push) Has been cancelled
UI Preview / build (push) Has been cancelled
OSS Scorecard / Scorecard analysis (push) Has been cancelled
UI Preview / cleanup (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
web Tests / web test (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
Windows (native) / Windows smoke + unit (push) Has been cancelled
Lint / Pre-commit checks (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
UI Preview / deploy (push) Has been cancelled
Native hook subprocesses (codex, claude, kimi, hermes, cursor) and the pi/opencode
JS extensions have been POSTing directly to the Omnigent server with a baked
30-minute bearer token. After expiry, every hook invocation pays ~1.7s for
credential re-discovery. The relay approach eliminates this class of failure
entirely by removing the server bearer from hook configs.
Changes:
relay handler (claude_native_bridge.py):
Add POST /policies/evaluate to the tool relay HTTP server. The relay
authenticates callers with its existing non-expiring local token and
proxies to the Omnigent server using asyncio.run_coroutine_threadsafe
with the runner's refresh-capable server_client (86400s timeout to
match ASK gate long-polls). session_id is written into tool_relay.json
so hook subprocesses can identify the session without a separate config.
runner/app.py:
Pass server_client and session_id to start_tool_relay so the relay can
serve the /policies/evaluate proxy endpoint.
native_policy_hook.py:
Add read_relay_policy_config(bridge_dir) helper that reads tool_relay.json
and returns (relay_url, relay_token, session_id), and relay_policy_evaluate_url.
Add _RELAY_URL_ENV / _RELAY_TOKEN_ENV constants for env-var harnesses.
hook subprocesses (codex, claude, kimi):
Read tool_relay.json first via read_relay_policy_config; fall back to
direct server call (policy_hook.json / permission_hook.json) when the
relay is not yet up. Remove _PersistingReauth from codex_native_hook.
hermes/cursor hook subprocesses:
Check _OMNIGENT_RELAY_URL / _OMNIGENT_RELAY_TOKEN env vars; fall back to
existing _OMNIGENT_AUTH_HEADERS path when absent.
hermes_native_bridge.py:
Add inject_relay_into_policy_hook which rewrites omnigent-policy-hook.sh
with relay env vars after ensure_comment_relay runs.
orchestration.py:
Wire ensure_comment_relay into _auto_create_pi_terminal (new param) and
inject relay coords into pi config.json and hermes wrapper script after
relay starts. Wire ensure_comment_relay into opencode policy_env via
OMNIGENT_RELAY_FILE. Remove _policy_hook_auth_loop and related refresh
machinery (_register/_unregister_policy_hook_auth, _POLICY_HOOK_AUTH_SESSIONS).
pi extension JS:
Add relayCredentials() that re-reads config.json for relayUrl/relayToken
on each call; evalNativePolicyHttp prefers relay URL and token over direct
server call.
opencode plugin JS:
Add relayCredentials() that re-reads OMNIGENT_RELAY_FILE (tool_relay.json)
on each call; evaluate() prefers relay over direct server call.
pi_native_bridge.py:
Add inject_relay_into_config to write relayUrl/relayToken into config.json.
All harnesses keep a direct-server fallback so sessions started before the
relay is up (first-call race) continue to work. The relay path is taken on
every subsequent call once tool_relay.json is written.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* feat(models): discover Kiro picker catalog
Replace the curated Kiro model picker table with the CLI's JSON model listing so newly released, renamed, or retired Kiro models no longer require an Omnigent source update.
Run discovery on the bound runner, expose it through a dedicated model-options endpoint, and reuse the server's asynchronous single-flight cache so snapshots never block on the CLI process.
Preserve Kiro-provided default, description, context-window, and credit-rate metadata in picker rows, remove four hardcode allowances, and document the discovery boundary.
Tests: 115 Kiro, runner lifecycle, and server snapshot tests; staged pre-commit run; manual validation against kiro-cli 2.10.0 output.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* test(kiro): cover picker discovery failures
Exercise the runner endpoint's retryable 503 path when Kiro CLI model discovery fails so the server keeps its picker cache cold instead of treating failure as an empty successful catalog.
Extend the session snapshot round-trip to verify provider descriptions and rate units survive NativeModelOption's extra-field wire schema alongside context windows and rate multipliers.
Tests: 116 Kiro, runner lifecycle, and snapshot tests passed. Live kiro-cli 2.15.1 discovery returned nine models with auto as the sole default. Targeted pre-commit passed.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(kiro): narrow discovered picker contract
Remove the unused kiro_base_model_options compatibility alias because its old pure lookup contract became a blocking CLI subprocess and no production caller remains.
Stop emitting isCurrent for Kiro because the CLI discovery response does not provide current-session state and the Web picker derives the selected row from model_override.
Cover missing and mismatched CLI defaults in the discovery mapper and verify that the Web picker falls back to its Default sentinel, leaving Kiro responsible for choosing the actual default. Refresh the Kiro picker E2E fixture and wording to match live discovery.
Tests: 118 focused Kiro/runner/snapshot tests; 4,705 Web tests; targeted pre-commit checks.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
First half of the runner launch seam. Wires the provider's auto_create_terminal
field (declared since 1.1, never dispatched) and collapses the 8 uniform
create-session launch arms in runner/app.py onto it. Behavior-preserving.
- orchestration: add NativeLaunchContext (flat dataclass of the inputs the 11
builders may need, incl. claude's closures), PreLaunchResult (skip /
force_recreate / needs_terminal for the special arms in 1.5b-ii), 11 thin
_launch_<x>(ctx) adapters that unpack the context and call the unchanged
_auto_create_<x>_terminal builder with that harness's exact kwarg subset, and
the shared shell _launch_native_terminal(harness, ctx, *, ensure_locks,
pre_launch=None, resolve_agent_spec=None). The shell runs the lock /
existence-check / pending+error-event mechanics every arm shared and resolves
the adapter via resolve_hook(provider, "auto_create_terminal").
- Option A (adapters, builders unchanged) keeps the 21 direct-call builder tests
intact. agent_spec is resolved lazily via resolve_agent_spec inside the create
block, preserving each arm's error semantics (pi unwrapped; cursor/opencode/
kimi swallow OmnigentError via _resolve_session_agent_spec_or_none; the rest
pass no resolver).
- harness_plugins: repoint auto_create_terminal to omnigent.runner.native:_launch_<key>.
- app.py: the 8 uniform arms (pi, cursor, kiro, opencode, goose, hermes, qwen,
kimi) become one _launch_native_terminal call each, picking the per-harness
lock dict (kept app-scope so session cleanup can pop by name). Net -256 lines.
- qwen's launch-error label is now "Qwen Code" (uniform display_name) vs the
former lowercase "qwen" — cosmetic; no test asserted the literal.
Deferred to 1.5b-ii: the 3 special arms (claude/codex/antigravity) and the
turn-path opencode cold-boot, which still use the direct builders.
Tests: unit-cover each adapter's kwarg subset and the shell's branches
(create / existing-skip / force-recreate teardown / skip+needs_terminal /
start-error event / lazy-spec-only-on-create / non-native None). The workflow-
init HTTP suite exercises the real launch path for the uniform arms and stays
green. Pre-existing codex gateway-env failures in events_lifecycle are unchanged
(verified identical on clean main; codex arm untouched here).
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Remove the release-specific smart-routing model table and require the runner worker catalog for routing candidates. When discovery is unavailable, leave the harness on its provider-resolved default instead of selecting a stale fallback.
Order catalog candidates by normalized provider-relative cost tiers while preserving catalog order as the tie-breaker, and express the built-in judge rubric through stable fast, balanced, and powerful intents rather than vendor model-name tiers.
Apply the same discovery-only rule to sys_advise_models, ratchet eight hardcode allowances, and document the remaining wire-compatibility exclusions as a separate migration boundary.
Tests: 67 focused routing/session tests; staged pre-commit run.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* feat(models): resolve runtime defaults from catalogs
Adapt MLflow provider listings into normalized resolver candidates with tri-state capability metadata, context windows, provider-relative cost tiers, and deterministic family filtering.
Replace release-specific defaults across workflow ucode routing, SDK executors, Databricks execution, and Claude/Codex/Pi/OpenCode native launch paths. Explicit request, spec, ucode, and provider-configured models continue to win; unresolved defaults now use the active provider catalog and fail clearly when discovery has no compatible model.
Improve model-version sorting so provider prefixes, dates, endpoint sizes, and unrelated numeric families do not distort catalog order. Ratchet ten obsolete hardcode allowances and document the runtime migration boundary.
Tests: 168 catalog/workflow tests; 519 executor tests; 449 native tests; staged pre-commit run.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): honor overrides before defaults
Apply per-session and CLI model overrides to the effective executor spec before spawn-environment builders attempt provider default resolution. This keeps explicit request values authoritative when catalog lookup is unavailable.
Preserve an explicit OMNIGENT_MODEL value when --harness selects the runtime, and allow model-only E2E overrides when the YAML owns harness selection. Add deterministic fixture models to unrelated tests so catalog-disabled CI does not depend on discovery.
Tests: 8 catalog-disabled CI regressions; 284 broader CLI/runtime/runner tests; staged pre-commit including the hardcoded-model lint.
Part of #3426
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): preserve catalog default policy
Route default-intent catalog resolution through the existing general-purpose selection policy after family filtering. This retains specialty-model exclusion and provider pins while leaving non-default intents on metadata ranking.
Require dynamically discovered Databricks defaults to use gateway-routable databricks-prefixed ids, report actionable catalog misses to direct executor callers, and model context capacity from max input tokens rather than input plus output budgets.
Add regression coverage for constrained defaults, lagging provider pins, OpenAI specialty variants, Databricks Claude/OpenAI routing, and context-window normalization.
Tests: 85 focused catalog/provider tests passed; 150 broader tests produced 149 passes plus the documented ambient Claude-login failure. Live Databricks catalog verification found 14 Claude and 16 OpenAI entries, all gateway-prefixed. Pre-commit passed all relevant hooks; repository-wide web-prettier and stale routing protobuf checks remain baseline failures.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): offload catalog discovery
Run cold catalog resolution on the existing dedicated thread helper from async Codex, Databricks, Open Responses, OpenAI Agents, and Pi turn paths. Model-less first turns can now wait for remote discovery without blocking the shared event loop for the catalog timeout.
Keep explicit and configured model precedence synchronous and unchanged. Make Pi's internal model resolver async so its Databricks fallback follows the same non-blocking boundary.
Add a regression that verifies catalog discovery executes outside the event-loop thread and update Pi resolver tests for the async contract.
Tests: 405 affected executor tests passed. Targeted pre-commit passed, including formatting, Ruff, and the hardcoded-model lint.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* fix(models): offload Claude catalog lookup
Move the remaining Claude SDK Databricks catalog fallback onto the dedicated thread helper so a cold remote lookup cannot block the async turn loop.
Restore direct Pi coverage tying a catalog-selected Databricks default to dynamic models.json registration. This preserves the prior unknown-model invariant even when the selected gateway id is newer than Pi's curated static entries.
Tests: 251 Claude SDK and Pi executor tests passed with the documented macOS path-canonicalization test deselected. Targeted pre-commit passed, including Ruff and the hardcoded-model lint.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
---------
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
* refactor(harness): route native spawn-env through the provider seam (PR 1.5a)
Collapse the two near-identical 11-arm native spawn-env dispatch chains in
runner/app.py (create-session ~2567 and dispatch ~6092) onto the provider seam.
Each block becomes one guarded call to a registry-driven helper; net -171 lines
in app.py. Behavior-preserving — every native harness produces the identical
spawn env before/after.
- harness_plugins: populate `spawn_env_builder` on all 11 built-in providers
(uniform `omnigent.<key>_native_bridge:build_<key>_native_spawn_env`) and add
a `bridge_id_label_key` field, set to `omnigent.<key>_native.bridge_id` for
the three label-based harnesses (codex/opencode/antigravity). The label key is
derived (not imported) to keep harness_plugins import-light; a test pins the
derivation against the real bridge constants.
- runner/native/orchestration: add `_resolve_native_spawn_env(harness, session_id,
*, server_client, optional_labels)`. It resolves `provider.spawn_env_builder`
and handles the three shapes — bare (session id only), label (bridge id from
`bridge_id_label_key`), and two named specials: claude (bridge id via the
runner helper with a server-side fallback) and hermes (writes its policy-hook
config before building). Returns None for non-native harnesses so the caller
keeps its SDK spawn env. Re-exported via runner/native/__init__.
- runner/app: both blocks now call the helper; the per-harness bridge imports and
label-key reads are gone.
The two special-cases (claude/hermes) stay named branches in the helper rather
than fully data-driven provider fields — their only consumers are single call
sites, and 1.5b's NativeLaunchContext will reshape the right calling convention.
Tests: extend the provider-paths-resolve + required-hooks tests to cover
spawn_env_builder; pin bridge_id_label_key against the real constants; add
`_resolve_native_spawn_env` unit coverage for all four shapes + the non-native
None path. The existing workflow_init codex-bundle-dir spawn-env test (the
end-to-end behavior-preservation proof) stays green unchanged.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
* test(harness): hoist spawn-env test imports to module level
Move the per-test `_resolve_native_spawn_env` and
`CODEX_NATIVE_BRIDGE_ID_LABEL_KEY` imports (added in 1.5a) up to the module
import block. No behavior change; test-only cleanup.
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
---------
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
## Related issue
N/A
## Summary
- `@testing-library/jest-dom` never declares `vitest` as a (peer) dependency.
Under pnpm's store layout, jest-dom's `declare module "vitest"` matcher-type
augmentation can't resolve `vitest`, so it silently fails to merge and `tsc`
loses every DOM matcher (`toBeInTheDocument`, `toHaveClass`, …) — even though
they register fine at runtime. See vitest-dev/vitest#10411.
- Declare the missing peer via pnpm `packageExtensions` so pnpm links `vitest`
into jest-dom's scope and the augmentation resolves. This is a root-cause fix
at the dependency layer — no hand-written type shim needed.
- Note: `type-check` still has unrelated pre-existing errors and is not yet
gated in CI; this fix only removes the jest-dom matcher category.
## Test Plan
- `pnpm install --frozen-lockfile --filter web` — lockfile stays consistent.
- `pnpm --filter web run type-check` — jest-dom matcher errors drop from 1589
to 0 (remaining errors are unrelated and pre-existing).
- `pnpm --filter web run test` (e.g. `src/shell/WorkspacePanel.test.tsx`) —
15/15 pass under Node 22; runtime is unaffected.
## 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
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Verified by comparing `pnpm --filter web run type-check` jest-dom error counts
(1589 → 0) and running the existing vitest suite (unaffected). The change is
dependency-resolution config only, with no new runtime code to test.
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
The module docstring only showed the session policy REST API, implying
CEL policies can't be declared statically. Both static paths work and
are now shown: config.yaml policies (handler + factory_params, parsed
by omnigent.inner.loader) and bundled agent specs (guardrails.policies
with a function {path, arguments} mapping, parsed by
omnigent.spec.parser — which does not read factory_params). Verified
both forms against their parsers.
Signed-off-by: Harry Su <tiecheng.su@robinhood.com>
* fix: add codex_cli_version to fake app-servers in tests; fix ruff format
- Add codex_cli_version = None to all _FakeCodexAppServer classes so they
satisfy the new attribute read in the orchestration bypass_hook_trust gate
- Collapse the multiline boolean in orchestration to satisfy ruff format
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: symlink hooks.json into private CODEX_HOME so user hooks fire
hooks.json was never symlinked, so user hooks declared there were silently
ignored in private sessions. Add it to _CODEX_HOME_GLOBAL_INSTRUCTION_FILES
so it's symlinked in full sessions but skipped in minimal_config (title
worker) mode. Trust is no longer a concern since --dangerously-bypass-hook-trust
is passed to runner-owned TUI sessions.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix: merge user hooks.json into policy hooks file instead of clobbering symlink
_write_codex_policy_hooks_file was using os.replace() which destroyed the
hooks.json symlink created by _populate_codex_home_config, silently dropping
all user hooks. Now when the path is a symlink, we read the user's hooks,
merge them after the policy hooks for each event (plus any user-only events),
remove the symlink, and write the merged payload as a regular file.
User hooks from ~/.codex/hooks.json now fire in private sessions.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* style: collapse _merge_user_hooks signature to one line (ruff format)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
---------
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Scopes the listing to system.ai models only via the parent filter and
raises the result cap to 1000, matching the recommended API call at
/ajax-api/2.1/unity-catalog/model-services?max_results=1000&parent=schemas%2Fsystem.ai.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
* fix(web): scale conversation sidebar text with the font-size setting
The sidebar's compact text was pinned to a fixed `--sidebar-font-size:
13px`, so the Appearance font-size setting only moved the surrounding
rem-based padding while the text stayed at 13px. Express the variable in
`rem` (0.8125rem = 13px at the 16px default) so it rides the root
font-size, which already folds in `--ui-font-scale` and the mobile bump.
Drop the explicit `line-height` on `.sidebar-compact-text`: single-line
rows use fixed height + flex centering (line-height inert), and the two
line-clamped previews now inherit the root's unitless 1.5, which scales
with the text for free.
Co-authored-by: Isaac
* test(e2e-ui): regenerate visual baselines
---------
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-29 14:07:08 +08:00
1255 changed files with 135319 additions and 20883 deletions
description: Run the Omnigent load test and produce a results file explaining the latencies. Load when the user wants to load-test / stress-test / benchmark Omnigent under concurrency ("load test omnigent", "stress test the server", "how many hosts/sessions/turns can it handle", "load test real agent turns / conversations", "run a load test"). The test makes each simulated user a real omnigent host that creates host-bound sessions and drives real multi-turn conversations with a mocked LLM; it boots its own local stack (dev/loadtest/run.py). Gather inputs, run it, then read the generated summary.md and explain the latency distribution (avg/median/p95/p99, throughput, failures). NOT for single-request latency micro-benchmarks (that is dev/benchmarks/).
---
# Run the Omnigent load test
Drives `dev/loadtest/` end to end: collect inputs → run → read `summary.md` →
explain the latencies. **Each Locust user is a real `omnigent host`** that
registers over the host tunnel, creates host-bound sessions, and drives **real
multi-turn conversations** — every turn is a genuine post→idle loop through the
host's runner, with the **LLM mocked** (zero latency) so the numbers are
Omnigent's own overhead. `-u N` scales the number of hosts.
It **boots its own local stack** (server + mock LLM), so there is no server to
point at, and it runs **from a repo checkout** only. For single-request latency
micro-benchmarks (not concurrency), that is a different tool: `dev/benchmarks/`.
"definition":"The web frontend (web/) shared by all clients: React UI, components, embed. NOT the desktop or mobile app shells (those are separate areas below).",
"paths":[
"web/"
@@ -64,6 +80,9 @@
{
"key":"desktop-app",
"label":"comp:web-ui",
"priority_label":"comp:web-ui",
"weight":1.0,
"weight_source":"editorial",
"definition":"The desktop app shell (Electron wrapper around the web UI): main process, packaging, native desktop chrome.",
"paths":[
"web/electron/"
@@ -77,6 +96,9 @@
{
"key":"mobile-app",
"label":"comp:web-ui",
"priority_label":"comp:ios",
"weight":1.0,
"weight_source":"editorial",
"definition":"The mobile app shell (iOS wrapper around the web UI): native mobile integration and packaging.",
"paths":[
"web/ios/"
@@ -87,9 +109,28 @@
"daniellok-db"
]
},
{
"key":"android-app",
"label":"comp:web-ui",
"priority_label":"comp:android",
"weight":1.0,
"weight_source":"editorial",
"definition":"The Android app shell: native Android integration and packaging.",
"paths":[
"web/android/"
],
"owners":[
"serena-ruan",
"fanzeyi",
"daniellok-db"
]
},
{
"key":"inner",
"label":"comp:harnesses",
"priority_label":"comp:harness-t2",
"weight":1.1,
"weight_source":"editorial",
"definition":"Core agent runtime and the harness/executor layer shared by all harnesses (loader, executor base, tool bridge, sandboxes). Harness-specific code has its own areas below.",
"paths":[
"omnigent/inner/"
@@ -97,18 +138,20 @@
"owners":[
"dhruv0811",
"TomeHirata",
"SabhyaC26",
"bbqiu",
"fanzeyi",
"aravind-segu"
"dbczumar"
],
"owners_paused":[
"dbczumar"
"aravind-segu"
]
},
{
"key":"runner",
"label":"comp:runner",
"priority_label":"comp:runner",
"weight":1.2,
"weight_source":"editorial",
"definition":"The agent runner: the execution engine that drives a turn.",
"paths":[
"omnigent/runner/"
@@ -117,15 +160,18 @@
"dhruv0811",
"bbqiu",
"fanzeyi",
"aravind-segu"
"dbczumar"
],
"owners_paused":[
"dbczumar"
"aravind-segu"
]
},
{
"key":"runtime",
"label":"comp:runner",
"priority_label":"comp:runner",
"weight":1.2,
"weight_source":"editorial",
"definition":"The agent runtime and execution scaffolding surrounding the runner.",
body="$(printf 'Drafts a feature-blog post for **%s**, selected by `feature-blog-scout` at the %s release cut.\n\n> **This is a DRAFT.** Before merging, a human must: record the mandatory demo (replace the `DEMO REQUIRED` marker) and do a final voice pass. The hero image and `author: omnigent` byline are auto-generated — review and optionally replace them.\n\n%s\n\nSource release: %s%s\n<sub>Generated by omnigent `.github/workflows/feature-blog.yml`. Review for accuracy before merging.</sub>' "$title" "$TAG" "$summary" "$TAG" "$mention")"
# Reference table of the contributing PRs and whether each already
# ships a demo video (built in the Draft posts step). Reviewers can pull
# an existing recording from a ✅ PR to replace the `DEMO REQUIRED`
# marker instead of re-recording. Omitted if the table wasn't produced.
demo_table=""
if [ -f "/tmp/demo_table_${idx}.md" ]; then
demo_table="$(printf '\n\n### Source PRs — demo videos\nCheck a ✅ PR for a recording you can drop into the `DEMO REQUIRED` marker.\n\n%s\n' "$(cat "/tmp/demo_table_${idx}.md")")"
fi
body="$(printf 'Drafts a feature-blog post for **%s**, selected by `feature-blog-scout` at the %s release cut.\n\n> **This is a DRAFT.** Before merging, a human must: record the mandatory demo (replace the `DEMO REQUIRED` marker) and do a final voice pass. The hero image and `author: omnigent` byline are auto-generated — review and optionally replace them.\n\n%s%s\n\nSource release: %s%s\n<sub>Generated by omnigent `.github/workflows/feature-blog.yml`. Review for accuracy before merging.</sub>' "$title" "$TAG" "$summary" "$demo_table" "$TAG" "$mention")"
existing="$(gh pr list --repo "$SITE_REPO" --head "$branch" --state open --json url --jq '.[].url' | head -n1)"
# exempts gh from `set -e`, so a non-zero exit hits the else branch.)
if gh pr create --base main --head "$BRANCH" \
--title "chore(oss): regenerate public lockfiles against public PyPI/npm" \
--body "Automated: regenerated uv.lock + web/package-lock.json against public PyPI/npm, validated by a Docker build + omnigent --help smoke (run ${{ github.run_id }}). Merge to keep the public lockfiles current and buildable."; then
--body "Automated: the lockfiles were out of sync with the manifests, so uv.lock + pnpm-lock.yaml were regenerated against public PyPI/npm and validated by a Docker build + omnigent --help smoke (run ${{ github.run_id }}). Merge to keep the public lockfiles consistent and buildable."; then
echo "Opened the regen PR."
else
echo "::warning::Could not open the regen PR automatically (the GITHUB_TOKEN may be disallowed from creating PRs). The branch '$BRANCH' is pushed with the regenerated lockfiles — open the PR by hand:"
`@${author} Thanks for the PR! It doesn't reference an issue yet.
**We require an issue for every PR**, so the work can be prioritized before it's reviewed. Add one to the description:
- \`Closes #123\` if this PR finishes the issue. That links it, gives your PR the issue's priority, and closes the issue when this merges. You can also link it from the **Development** section of the sidebar.
- \`Part of #123\` if this is one step towards it. \`Related to\`, \`Towards\`, and \`Refs\` work the same way, and leave the issue open.
No issue exists for this yet? Open one first, then reference it. That's how we track what's worth doing, and it's usually quicker than it sounds. Note a reference has to point at an issue: naming another PR doesn't count.
The only exceptions are changes with no user-visible behaviour: pure **Refactor / chore**, **Docs**, or **Test / CI** work. If that's genuinely what this is, check that box under *Type of change*. Anything that fixes a bug, adds a feature, or changes the UI needs an issue, even when it also touches docs or tests.
See [CONTRIBUTING.md](https://github.com/omnigent-ai/omnigent/blob/main/CONTRIBUTING.md#every-pr-needs-an-issue) for the full policy.
_No action is taken beyond this comment._`;
module.exports=async({context,github,core})=>{
const{owner,repo}=context.repo;
// Default to a dry run: enforcement is opt-in via the workflow env.
constenforce=process.env.ENFORCE==="true";
// Unset means unlimited; an explicit LIMIT=0 means flag nothing. A malformed
// value flags nothing rather than everything -- this bounds how many
// contributors one run may comment on, so the safe default is the low one.
constrawLimit=process.env.LIMIT;
letlimit=Infinity;
if(rawLimit!==undefined&&rawLimit!==""){
limit=Number(rawLimit);
if(!Number.isFinite(limit)){
core.warning(`LIMIT=${rawLimit} is not a number; flagging nothing this run.`);
limit=0;
}
}
try{
// Load maintainers from the API, not the checked-out tree, so a PR can't
// self-grant by editing the file (same approach as demo-check.js).
body="$(printf 'Publishes the **%s** release post at `/releases/%s` — the curated GitHub Release notes reformatted into the site'"'"'s narrative, prose-driven style.\n\nGenerated by omnigent `.github/workflows/publish-changelog.yml`. Edit the GitHub Release, not this file.' "$TAG" "$VERSION")"
# Append the demo-video reference table: which feature PRs already ship a
# recording a reviewer can drop into the post'"'"'s `TODO` demo placeholders.
if [ -s /tmp/demo_table.md ]; then
body="$(printf '%s\n\n### Source PRs — demo videos\nCheck a ✅ PR for a recording to replace a `TODO` demo placeholder in the post.\n\n%s' "$body" "$(cat /tmp/demo_table.md)")"
assert.ok(issueLink.EFFECTIVE_FROM,"shares the issue-link effective date");
}
console.log("ready-for-review.test.js: all assertions passed");
})();
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.