Compare commits

...

67 Commits

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

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

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

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

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

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

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

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

* test(e2e_ui): cover iOS OIDC browser handoff

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

* test(e2e_ui): avoid exfil scan false positive

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

---------

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

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

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

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

Fixes OMNI-3274.

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

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

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

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

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

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

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

OMNI-4255

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

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

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

OMNI-4255

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

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

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

OMNI-4255

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

---------

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

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

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

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

* fix: chain migration after current alembic head

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

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

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

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

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

* fix: chain migration after merged main head za2b3c4d5e6f

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

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

---------

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

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

* Bugfix

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

* Bugfix

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

* Bugfix only [no add icon option]

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

N/A

## Summary

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

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

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

## Test Plan

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

## Demo

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

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

N/A

## Summary

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

## Test Plan

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

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

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

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

N/A

## Summary

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

## Test Plan

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

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

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

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

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

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

Fixes OMNI-1127 / closes #1953.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-20 19:14:41 -07:00
Zeyi (Rice) Fan f914095c1d fix(cli): preserve Databricks profile shorthand (#5143)
## Related issue

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

## Summary

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

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

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

## Test Plan

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

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

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

## Summary

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

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

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

## Test Plan

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

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

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

## Summary

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

## Test Plan

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

## Demo

N/A — packaging-only change.

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

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

## Summary

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

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

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

## Test Plan

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

## Demo

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

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

N/A

## Summary

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

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

## Test Plan

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

## Demo

N/A — non-visual release packaging change.

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Isaac

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

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

* Preserve loopback reconnect tolerance

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

* Trigger CI retry

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

---------

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

N/A

## Summary

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

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

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

## Test Plan

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

## Demo

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

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Serve every model surface from the shared harness catalog

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

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

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

* Confirm model switches through the harness before claiming them

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Type full model ids verbatim on unpinned claude sessions

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

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

* Defer mid-turn model switches instead of failing them

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

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

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

* Mark a provider launch pin as the claude catalog default

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

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

* Merge origin/main and green the CI suite

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Co-authored-by: Isaac

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

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

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

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

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

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

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

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

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

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

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

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

Closes #5123

## Summary

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

## Test Plan

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

## Demo

N/A — this changes native navigation recovery without adding or changing visual UI.

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* fix(web): snapshot only launched harness options

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

* fix(web): remember Codex bypass mode

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* fix(tools): use cursors for discovery pagination

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

* test(e2e): cover discovery cursor compatibility

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

* fix(tools): harden discovery cursor continuation

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

Co-authored-by: Isaac

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

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

Deliberately narrow:

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

Co-authored-by: Isaac

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

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

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

Co-authored-by: Isaac

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

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

* Address feedback

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

---------

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

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

* Test fix

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

* Test fixes, round two

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

* Address review

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

---------

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

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

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

Two behaviors worth calling out:

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

* fix(runner): tighten Claude cleanup idempotency

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

* fix(runner): ignore vanished Claude timeout targets

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

---------

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

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

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

Closes #3004

Co-authored-by: Isaac


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

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

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

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

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

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

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

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

Co-authored-by: Isaac

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

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

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

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

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

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

* style: fix formatting in usage page e2e test

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-08-19 14:04:09 -07:00
299 changed files with 25939 additions and 3571 deletions
+62 -5
View File
@@ -21,6 +21,7 @@ REVIEW_LABEL = "waiting-for-review"
WAITING_DAYS = 7
CANONICAL_REPO = "omnigent-ai/omnigent"
MAX_CLOSURES_PER_RUN = 30
REVIEW_EVENTS = {"pull_request_review", "pull_request_review_comment"}
def label_names(item: dict[str, Any]) -> list[str]:
@@ -114,6 +115,16 @@ class GitHubAPI:
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
return pull
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
review, _ = self.request(
"GET", f"/repos/{self.repo}/pulls/{pull_number}/reviews/{review_id}"
)
return review
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
comment, _ = self.request("GET", f"/repos/{self.repo}/pulls/comments/{comment_id}")
return comment
def remove_label(self, issue_number: int, label: str) -> bool:
quoted = urllib.parse.quote(label, safe="")
try:
@@ -355,10 +366,14 @@ def apply_waiting_on_maintainer_activity(
pull_number = payload["pull_request"]["number"]
review = payload.get("review") or {}
actor = (review.get("user") or {}).get("login")
review_state = (review.get("state") or "").lower()
# An approval asks nothing of the author; it means the PR is ready.
if (review.get("state") or "").lower() == "approved":
if review_state == "approved":
print(f"#{pull_number}: approving review, leaving the label alone.")
return False
if review_state not in {"commented", "changes_requested"}:
print(f"#{pull_number}: {review_state or 'unknown'} review, leaving the label alone.")
return False
if is_slash_command(review.get("body")):
return False
reason = "a maintainer reviewed"
@@ -473,6 +488,42 @@ def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
return closed
def relay_integer(record: dict[str, Any], field: str) -> int:
value = record.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"Relay field {field!r} must be a positive integer")
return value
def hydrate_relay_event(
record: dict[str, Any], api: GitHubAPI, repo: str, expected_event: str
) -> tuple[str, dict[str, Any]]:
event_name = record.get("event_name")
if event_name not in REVIEW_EVENTS:
raise ValueError(f"Unsupported relayed event: {event_name!r}")
if event_name != expected_event:
raise ValueError(
f"Relayed event {event_name!r} does not match workflow event {expected_event!r}"
)
pull_number = relay_integer(record, "pull_number")
activity_id = relay_integer(record, "activity_id")
pull = api.get_pull(pull_number)
base_repo = ((pull.get("base") or {}).get("repo") or {}).get("full_name") or ""
if base_repo.lower() != repo.lower():
raise ValueError(f"Relayed PR #{pull_number} targets {base_repo!r}, not {repo!r}")
if event_name == "pull_request_review":
review = api.get_review(pull_number, activity_id)
return event_name, {"pull_request": pull, "review": review}
comment = api.get_review_comment(activity_id)
expected_url = f"https://api.github.com/repos/{repo}/pulls/{pull_number}"
if comment.get("pull_request_url") != expected_url:
raise ValueError(f"Review comment {activity_id} does not belong to PR #{pull_number}")
return event_name, {"pull_request": pull, "comment": comment}
def run(
event_name: str,
payload: dict[str, Any],
@@ -495,8 +546,7 @@ def run(
apply_waiting_on_maintainer_activity(event_name, payload, api)
def load_event_payload() -> dict[str, Any]:
path = os.environ.get("GITHUB_EVENT_PATH")
def load_json(path: str | None) -> dict[str, Any]:
if not path:
return {}
with open(path, encoding="utf-8") as handle:
@@ -509,8 +559,15 @@ def main() -> int:
if not token:
print("GITHUB_TOKEN is required", file=sys.stderr)
return 1
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run(event_name, load_event_payload(), GitHubAPI(token, repo), repo)
api = GitHubAPI(token, repo)
relay_path = os.environ.get("WAITING_ON_AUTHOR_RELAY_PATH")
if relay_path:
expected_event = os.environ.get("WAITING_ON_AUTHOR_RELAY_EVENT", "")
event_name, payload = hydrate_relay_event(load_json(relay_path), api, repo, expected_event)
else:
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
payload = load_json(os.environ.get("GITHUB_EVENT_PATH"))
run(event_name, payload, api, repo)
return 0
+118
View File
@@ -63,6 +63,8 @@ class FakeAPI:
issue_comments: dict[int, list[dict[str, Any]]] | None = None,
review_comments: dict[int, list[dict[str, Any]]] | None = None,
reviews: dict[int, list[dict[str, Any]]] | None = None,
review_by_id: dict[tuple[int, int], dict[str, Any]] | None = None,
review_comment_by_id: dict[int, dict[str, Any]] | None = None,
commits: dict[int, list[dict[str, Any]]] | None = None,
writers: list[str] | None = None,
):
@@ -73,6 +75,8 @@ class FakeAPI:
self.issue_comments = issue_comments or {}
self.review_comments = review_comments or {}
self.reviews = reviews or {}
self.review_by_id = review_by_id or {}
self.review_comment_by_id = review_comment_by_id or {}
self.commits = commits or {}
self.removed: list[tuple[int, str]] = []
self.closed: list[int] = []
@@ -83,6 +87,12 @@ class FakeAPI:
def get_pull(self, pull_number: int) -> dict[str, Any]:
return self.pull | {"number": pull_number}
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
return self.review_by_id[(pull_number, review_id)]
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
return self.review_comment_by_id[comment_id]
def remove_label(self, issue_number: int, label: str) -> bool:
self.removed.append((issue_number, label))
return True
@@ -442,6 +452,21 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
)
self.assertEqual(api.added, [])
def test_dismissed_review_leaves_the_label_alone(self) -> None:
api = self.dispatch(
"pull_request_review",
{
"pull_request": {"number": 12},
"review": {
"user": {"login": "maintainer1"},
"state": "dismissed",
"body": "stale feedback",
},
},
pull=pr(labels=[]),
)
self.assertEqual(api.added, [])
def test_commenting_review_applies_the_label(self) -> None:
api = self.dispatch(
"pull_request_review",
@@ -483,6 +508,99 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
)
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
def test_relayed_review_rehydrates_trusted_api_data(self) -> None:
pull = pr(labels=[]) | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
api = FakeAPI(
pull=pull,
review_by_id={
(12, 41): {
"id": 41,
"user": {"login": "maintainer1"},
"state": "changes_requested",
"body": "please fix",
}
},
)
event, payload = waiting_on_author.hydrate_relay_event(
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review",
)
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
def test_relayed_review_comment_rehydrates_author_reply(self) -> None:
pull = pr(author="alice") | {
"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}
}
api = FakeAPI(
pull=pull,
review_comment_by_id={
73: {
"id": 73,
"user": {"login": "alice"},
"body": "fixed",
"pull_request_url": (
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/12"
),
}
},
)
event, payload = waiting_on_author.hydrate_relay_event(
{
"event_name": "pull_request_review_comment",
"pull_number": 12,
"activity_id": 73,
},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_relayed_review_comment_must_match_pull(self) -> None:
pull = pr() | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
api = FakeAPI(
pull=pull,
review_comment_by_id={
73: {
"id": 73,
"pull_request_url": (
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/99"
),
}
},
)
with self.assertRaisesRegex(ValueError, "does not belong"):
waiting_on_author.hydrate_relay_event(
{
"event_name": "pull_request_review_comment",
"pull_number": 12,
"activity_id": 73,
},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
def test_relayed_event_must_match_workflow_event(self) -> None:
api = FakeAPI()
with self.assertRaisesRegex(ValueError, "does not match workflow event"):
waiting_on_author.hydrate_relay_event(
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
def test_applying_clears_waiting_for_review(self) -> None:
api = self.dispatch(
"issue_comment",
+28 -26
View File
@@ -25,8 +25,10 @@ name: Issue Triage
# 5. Flags incomplete issues — `needs-info` (replaces priority label)
# 6. Optionally comments when a duplicate or related issue is found
# (disabled by default; never comments when nothing matches)
# 7. Optionally closes validated high-confidence duplicates (disabled by default)
# 8. Assigns P0/P1 issues to a maintainer via round-robin
# 7. Assigns an owner to every triaged open issue via round-robin — duplicates
# included, so the owner persists if the issue is later reopened
# 8. Optionally closes validated high-confidence duplicates (disabled by
# default), after the owner above is assigned
on:
issues:
@@ -714,40 +716,25 @@ jobs:
exit 0
fi
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
if [ "$duplicate_decision" = "duplicate" ]; then
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
if [ "$close_duplicate_issue" = "true" ]; then
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json state --jq '.state')
if [ "$issue_state" = "OPEN" ]; then
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
--duplicate-of "$duplicate_of"
fi
else
echo "Duplicate closure disabled; leaving issue open."
fi
exit 0
fi
# Assign an owner to every triaged open issue, BEFORE the duplicate
# closure below. Duplicates get an owner too, and it persists if the
# issue is later reopened — triage only fires on `opened`, so a
# reopened issue would otherwise come back unassigned.
# If the issue was filed by a maintainer, assign it to them directly.
author=$(jq -r '.author.login // empty' /tmp/issue.json)
maintainer_assigned=false
if [ -n "$author" ] && grep -qxF "$author" .github/MAINTAINER; then
echo "Issue filed by maintainer $author — assigning to author"
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json state --jq '.state')
if [ "$issue_state" = "OPEN" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
maintainer_assigned=true
fi
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
maintainer_assigned=true
fi
# Otherwise, assign an owner: the least-loaded area owner, with LLM
# rank as a tiebreaker (load primary, rank secondary). Symmetric with
# the PR reviewer path. Skipped if the maintainer-author was already
# assigned above. Every triaged issue gets an owner — the only issues
# assigned above. Every triaged issue gets an owner — duplicates
# included, even ones about to be closed below — so the only issues
# left unassigned are needs_info ones (too vague to route until the
# reporter adds detail).
needs_info=$(jq -r '.needs_info // false' /tmp/triage_result.json)
@@ -794,11 +781,26 @@ jobs:
assignee=$(cat /tmp/assignee.txt)
if [ -n "$assignee" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
fi
fi
# Finally, close the issue if it is a high-confidence duplicate and
# closure is enabled. The owner assigned above stays on the issue,
# ready if it is reopened.
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
if [ "$duplicate_decision" = "duplicate" ]; then
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
if [ "$close_duplicate_issue" = "true" ]; then
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json state --jq '.state')
if [ "$issue_state" = "OPEN" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
--duplicate-of "$duplicate_of"
fi
else
echo "Duplicate closure disabled; leaving issue open."
fi
fi
@@ -0,0 +1,78 @@
name: Waiting on Author Review Run
# Privileged half of the fork-review relay. The artifact contains numeric IDs
# only; the script re-fetches the PR and review/comment from GitHub before using
# actor identity, review state, or comment content. No PR code is checked out.
on:
workflow_run:
workflows: [Waiting on Author Review]
types: [completed]
permissions:
contents: read
concurrency:
group: waiting-on-author-review-run-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
hygiene:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Download recorded review event IDs
id: download
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const { owner, repo } = context.repo;
const arts = await github.rest.actions.listWorkflowRunArtifacts({
owner, repo, run_id: context.payload.workflow_run.id,
});
const art = arts.data.artifacts.find(
a => a.name === 'waiting-on-author-review-event'
);
if (!art) {
core.info('No fork-review artifact; the direct review job handled this event.');
core.setOutput('found', 'false');
return;
}
const dl = await github.rest.actions.downloadArtifact({
owner, repo, artifact_id: art.id, archive_format: 'zip',
});
fs.writeFileSync(
`${process.env.RUNNER_TEMP}/waiting-on-author-review.zip`,
Buffer.from(dl.data)
);
core.setOutput('found', 'true');
- name: Check out trusted .github
if: steps.download.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Apply relayed waiting-on-author state
if: steps.download.outputs.found == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
RELAY_ZIP: ${{ runner.temp }}/waiting-on-author-review.zip
RELAY_DIR: ${{ runner.temp }}/waiting-on-author-review
WAITING_ON_AUTHOR_RELAY_EVENT: ${{ github.event.workflow_run.event }}
WAITING_ON_AUTHOR_RELAY_PATH: ${{ runner.temp }}/waiting-on-author-review/event.json
run: |
mkdir -p "$RELAY_DIR"
unzip -q "$RELAY_ZIP" -d "$RELAY_DIR"
python3 .github/scripts/waiting_on_author.py
@@ -0,0 +1,71 @@
name: Waiting on Author Review
# Review events on fork PRs receive a read-only token. Same-repo reviews run the
# hygiene script directly; fork reviews record only GitHub-provided numeric IDs
# for the privileged workflow_run consumer. No PR code is checked out.
on:
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: read
concurrency:
group: waiting-on-author-review-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
hygiene:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check out .github
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Update waiting-on-author state
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 .github/scripts/waiting_on_author.py
record:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Record review event IDs
env:
EVENT_NAME: ${{ github.event_name }}
PULL_NUMBER: ${{ github.event.pull_request.number }}
ACTIVITY_ID: ${{ github.event.review.id || github.event.comment.id }}
run: |
mkdir -p relay
jq -n \
--arg event_name "$EVENT_NAME" \
--argjson pull_number "$PULL_NUMBER" \
--argjson activity_id "$ACTIVITY_ID" \
'{event_name: $event_name, pull_number: $pull_number, activity_id: $activity_id}' \
> relay/event.json
- name: Upload review event IDs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: waiting-on-author-review-event
path: relay/event.json
retention-days: 1
if-no-files-found: error
@@ -9,6 +9,8 @@ on:
- .github/scripts/waiting_on_author.py
- .github/scripts/waiting_on_author_test.py
- .github/workflows/waiting-on-author.yml
- .github/workflows/waiting-on-author-review.yml
- .github/workflows/waiting-on-author-review-run.yml
- .github/workflows/waiting-on-author-test.yml
workflow_dispatch:
+2 -4
View File
@@ -6,16 +6,14 @@ name: Waiting on Author Hygiene
# once a review is submitted). The two labels are mutually exclusive. PRs left
# waiting on the author for 7 days are closed. The workflow runs from trusted
# default-branch code and never checks out PR-authored files.
# Review events use the read-only -> workflow_run relay in
# waiting-on-author-review.yml and waiting-on-author-review-run.yml.
on:
pull_request_target:
types: [synchronize, labeled]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
+7
View File
@@ -159,6 +159,13 @@ POSTGRES_PASSWORD=change-me-please
# ── Optional OIDC tuning ─────────────────────────────────
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
#
# Absolute lifetime (days) of login-issued refresh grants — how long a
# host/CLI that logged in with `omnigent login` can keep renewing its
# access before a human must log in again. Default 30. Unattended hosts
# renew automatically within this window; each host replica needs its
# own login (refresh tokens rotate — shared copies revoke each other).
# OMNIGENT_GRANT_MAX_LIFETIME_DAYS=30
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
#
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
+88 -12
View File
@@ -17,9 +17,11 @@
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
> flow and never mounts these routes).
> `/oauth/device/*` consent routes are unmounted unless it is truthy), and
> then only in **accounts** mode (OIDC delegates login to the IdP via the
> cli-ticket flow and never mounts the consent routes). The token/revoke
> half (`/oauth/token`, `/oauth/revoke`) mounts **unconditionally** in both
> accounts and OIDC modes: login-issued refresh grants (below) need it.
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
@@ -182,15 +184,20 @@ approved it.
### Router `omnigent/server/routes/device_auth.py`
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
cli-ticket flow and never mounts these routes; header mode has no
server-mintable identity — see `create_device_auth_router`, which raises if
constructed for any other source). The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the router
mount is gated. This router **owns** `mint_delegated_token` and
`DELEGATED_SCOPE`.
The RFC 8628 consent surface (`/oauth/device/*`) is mounted in `app.py`
only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy** (opt-in,
**default-off**), and then **only in `accounts` mode** (the in-browser
consent needs the accounts login page; header mode has no server-mintable
identity — see `create_device_auth_router`, which raises if constructed for
any other source). The token/revoke half is factored into
`create_oauth_token_router` and mounts **unconditionally** in both accounts
and OIDC modes — login-issued refresh grants need `/oauth/token` even where
the device flow is off; a standalone mount refuses the `device_code` grant
type with `unsupported_grant_type`. The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the consent
mount is gated. This router also **owns** `mint_delegated_token` and
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
`mint_session_token` / `mint_session_cookie`).
- `POST /oauth/device/authorize`**public** (rate-limited). Generates a
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
@@ -355,3 +362,72 @@ stateless. This added invariant is the main thing for reviewers to scrutinize.
- Applying the same delegated grant to other non-browser clients (the CLI could
use it too, superseding the in-memory `_cli_tickets` store).
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
## Login-issued refresh grants
The fix for unattended hosts dying at session-JWT expiry (a host
authenticated via `omnigent login` previously had **no renewal path**
the stored `{token, user_id, expires_at}` record simply lapsed, default
8 h, and the next tunnel reconnect got a misleading 403).
- **Issuance** — both interactive login flows create a grant born
`redeemed` (`DeviceGrantStore.create_redeemed_grant`; the interactive
login *is* the consent step, so no device-code dance): the OIDC
cli-ticket fulfillment always, and accounts `POST /auth/login` when the
body carries `issue_refresh: true` (sent by the CLI, never the web
form — a browser must not receive refresh material). The raw refresh
token rides back once (`/auth/cli-poll` / the login response) as an
optional `refresh_token` key — old CLIs ignore it, new CLIs against old
servers see it absent. `client_id` is `"omnigent-cli"`.
- **Authority** — a refreshed login-grant token carries `grant_id` (so
revocation still kills it) but **no `scope` claim**, so the delegated
path allowlist does not apply: it renews the session JWT and keeps that
same authority. Scoping it like a third-party device grant instead made
every non-allowlisted route (`/v1/usage`, `/v1/scheduled-tasks`,
`/v1/policy-registry`) 401 after the first refresh. `LOGIN_GRANT_CLIENT_ID`
is the marker and is **reserved**`/oauth/device/authorize` refuses a
request naming it, so a device client cannot self-declare into this class.
- **No rotation** — login grants return the SAME refresh token on every
renewal. Rotation + reuse detection is right for a browser-adjacent
third-party client, but for an unattended host it turns any ambiguous
network failure (lost response, crash between the server committing and
the client persisting) into a permanently revoked grant. Only the
short-lived access token is renewed; revocation and the absolute lifetime
cap still bound exposure. Device grants keep rotating.
- **Renewal** — `omnigent.cli_auth.refresh_stored_token` POSTs
`grant_type=refresh_token`, persists the result, and returns the
fresh access token. The runner/host auth-token factory
calls it when the stored token lapses, and the host tunnel rebuilds
headers through that factory on every reconnect — so an unattended
host renews itself for the grant's lifetime. Refreshes on one machine
are serialized by an advisory file lock with a re-check after acquire,
so a losing racer picks up the winner's rotated pair instead of
replaying the stale token (which reuse detection would punish by
revoking the grant).
- **One grant per host replica (recommended)** — since login grants do
not rotate, N replicas sharing one token file no longer revoke each
other. A per-replica login is still preferred so a single host can be
revoked without cutting off the rest.
- **Lifetime** — the absolute grant lifetime stays 30 days by default;
`OMNIGENT_GRANT_MAX_LIFETIME_DAYS` lets an operator extend it
deliberately for long-lived unattended hosts. Invalid values fall back
to the default (never fail open to unbounded).
- **Client-secret interaction** — `OMNIGENT_DEVICE_CLIENT_SECRET` gates
the endpoints that mint from an ephemeral code (device authorize + the
`device_code` exchange). Refresh and revoke are NOT gated: the presented
refresh/access token is itself the credential, and a CLI renewing its own
login has no way to carry that secret (gating it made every automatic
refresh 401 in a Slack-serving deployment).
- **Housekeeping** — `/oauth/token` opportunistically purges expired and
aged-out grants. The device flow purges on `authorize`, which a
standalone token-router mount does not have, so login-grant rows would
otherwise accumulate one per login.
### Known limitation
General CLI commands (`omnigent usage`, session commands, direct
remote-URL chat) still read the stored token without attempting a refresh —
only the host/runner auth-token factory renews today. An expired login with
valid refresh material therefore leaves those commands unauthenticated
until something on the host path renews the shared file. Wiring the
remaining CLI entrypoints is follow-up work.
+14 -3
View File
@@ -72,11 +72,22 @@ they still work with no runner or LLM.
| Journey | Operation timed |
| --- | --- |
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
| `native_hook_spawn` | Spawn one **Python** command hook — isolated interpreter, module entrypoint, JSON payload on stdin — and time its whole lifetime |
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
lifetime is user-visible streaming latency, and the same interpreter+import
cost fronts every statusline refresh and per-tool-call policy hook. The
lifetime is user-visible latency. Read this number as *"what a hook costs if it
is Python"*.
It is **not** the per-chunk streaming cost, and treating it as one leads
straight to wasted work. The hooks that fire per chunk (`MessageDisplay`) and
per tool call (`evaluate-policy`) were deliberately moved off the interpreter —
a `/bin/sh` appender and a `curl` to the runner's relay — and
`test_message_display_shell_command_round_trips` pins that by asserting
`"python"` is absent from the installed command. What still pays this number is
the per-turn set (`SessionStart` / `Stop` / `UserPromptSubmit` / `PreCompact` /
`Task*`), the `PostToolUse` `TodoWrite`+`TaskUpdate` matchers, and the policy
hook's Python fallback before the relay is up. So the journey's real job is to
keep the argument for staying off the interpreter measurable. The
journey needs no server or runner; registering it here rides hook spawn cost
on the same nightly/release regression comparison as everything else
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
+22 -8
View File
@@ -881,12 +881,21 @@ async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> N
# ── native hook spawn (no server involved) ───────────────────
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
# lifetime is user-visible latency: the MessageDisplay hook runs once per
# streamed text chunk, and the same interpreter+import cost fronts every
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
# payload on stdin — and time the full process lifetime. The import-graph side
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
# lifetime is user-visible latency. This journey times that lifetime for a
# Python hook — isolated interpreter, module entrypoint, JSON payload on stdin —
# which is the cost of ANY hook the bridge installs as a `python -m` command.
#
# It is NOT the per-chunk streaming path. The hooks that fire per chunk
# (MessageDisplay) and per tool call (evaluate-policy) were deliberately moved
# off the interpreter — a /bin/sh appender and a curl to the runner's relay
# respectively — and tests pin that (`test_message_display_shell_command_round_trips`
# asserts "python" is absent from the installed command). What still pays this
# is the per-turn set (SessionStart / Stop / UserPromptSubmit / PreCompact /
# Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and the policy hook's
# Python fallback when the relay is not yet up. So read this number as
# "what a hook costs if it is Python", and as the standing argument for keeping
# the hot paths off it — not as a per-chunk cost. The import-graph side is
# pinned by tests/test_claude_native_message_display_hook.py.
_HOOK_SPAWN_PAYLOAD = json.dumps(
{
"hook_event_name": "MessageDisplay",
@@ -905,7 +914,7 @@ async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
"""Spawn one Python hook subprocess and wait, as Claude Code would."""
del env
proc = await asyncio.create_subprocess_exec(
sys.executable,
@@ -1082,7 +1091,12 @@ ALL_JOURNEYS: dict[str, Journey] = {
measure=_measure_hook_spawn,
setup=_setup_hook_spawn,
teardown=_teardown_hook_spawn,
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
description=(
"Spawn one Python command hook (isolated interpreter, module "
"entrypoint) and time its whole lifetime — what any `python -m` "
"hook costs Claude's blocked TUI. Not the per-chunk path: that "
"one is a /bin/sh appender."
),
),
Journey(
name="cli_startup",
+6 -1
View File
@@ -180,7 +180,12 @@ def _register_error_handler(app: AsyncApp, logger: logging.Logger) -> None:
@app.error
async def _on_error(error: Exception, body: dict[str, Any]) -> None:
logger.exception("Unhandled Slack listener error; body_type=%s", body.get("type"))
logger.error(
"Unhandled Slack listener error: %s; body_type=%s",
error,
body.get("type"),
exc_info=(type(error), error, error.__traceback__),
)
def register_handlers(app: AsyncApp, service: SlackOmnigentService) -> None:
+30 -5
View File
@@ -20,12 +20,37 @@ async def test_error_handler_logs_with_traceback(caplog: pytest.LogCaptureFixtur
error_handler = app._async_middleware_error_handler
assert error_handler is not None
with caplog.at_level(logging.ERROR, logger="test-app-error"):
def raise_error() -> RuntimeError:
try:
raise RuntimeError("boom")
except RuntimeError as exc:
await error_handler.func(error=exc, body={"type": "event_callback"})
return exc
assert any("Unhandled Slack listener error" in r.message for r in caplog.records)
# The exception traceback is attached (logger.exception), not just the message.
assert any(r.exc_info for r in caplog.records)
error = raise_error()
with caplog.at_level(logging.ERROR, logger="test-app-error"):
await error_handler.func(error=error, body={"type": "event_callback"})
record = caplog.records[-1]
assert "Unhandled Slack listener error: boom" in record.message
assert record.exc_info == (RuntimeError, error, error.__traceback__)
@pytest.mark.asyncio
async def test_error_handler_logs_exception_without_active_traceback(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An exception passed outside an ``except`` block still logs usefully."""
app = AsyncApp(token="xoxb-dummy", signing_secret="x")
logger = logging.getLogger("test-app-error-no-traceback")
_register_error_handler(app, logger)
error_handler = app._async_middleware_error_handler
assert error_handler is not None
error = ValueError("created outside an except block")
with caplog.at_level(logging.ERROR, logger="test-app-error-no-traceback"):
await error_handler.func(error=error, body={"type": "event_callback"})
record = caplog.records[-1]
assert "created outside an except block" in record.message
assert record.exc_info == (ValueError, error, None)
assert "NoneType: None" not in caplog.text
+13
View File
@@ -49,6 +49,19 @@ def record_post_failure(event_type: str, error: BaseException) -> None:
_state["last_post_failure"] = (time.monotonic(), f"{event_type}: {error!r}")
def record_transport_failure(detail: str) -> None:
"""Record an already-formatted transport failure into the shared slot.
The SDK codex head has no forwarder POST path, but its subprocess still
runs under the same idle-turn watchdog. When the model gateway rejects the
CLI (e.g. a 401 read off the CLI's stderr), the turn emits no events and the
watchdog would otherwise blame a generic "wedged LLM". Recording the parsed
cause here lets the watchdog attribute the real failure, exactly as the
native forwarder path does. *detail* is a ready-to-surface human string.
"""
_state["last_post_failure"] = (time.monotonic(), detail)
def note_post_success() -> None:
"""
Clear the failure record after a POST that reached the server.
+8
View File
@@ -146,6 +146,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
- Connection-establishment / pool-acquire failures
(:data:`_DELIVERY_SAFE_RETRY_ERRORS`): no bytes were sent → not
delivered → safe to retry, so ``False``.
- An unbound ``RequestError``: the failure occurred before httpx
associated the exception with the outbound request (for example,
an auth flow failed before yielding it). No bytes were sent → safe
to retry, so ``False``.
- Any other transport error (read/write timeout, read/write error,
remote protocol error): the request was sent and we never saw a
response, so the server may have processed it → ambiguous →
@@ -159,6 +163,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
return False
if isinstance(exc, _DELIVERY_SAFE_RETRY_ERRORS):
return False
try:
_ = exc.request
except RuntimeError:
return False
return True
+9 -5
View File
@@ -800,10 +800,13 @@ async def _prepare_antigravity_terminal_via_daemon(
_update_progress(startup_progress, "Creating Antigravity session...")
bridge_id = _mint_agy_conversation_id()
conversation_id = bridge_id
session_id = await _create_antigravity_session(
client,
session_bundle,
bridge_id=bridge_id,
session_id, _ = await asyncio.gather(
_create_antigravity_session(
client,
session_bundle,
bridge_id=bridge_id,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_progress(startup_progress, "Loading Antigravity session...")
@@ -847,7 +850,8 @@ async def _prepare_antigravity_terminal_via_daemon(
conversation_id = external if isinstance(external, str) and external else bridge_id
resume = isinstance(external, str) and bool(external)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+19 -14
View File
@@ -1595,22 +1595,23 @@ async def _prepare_chat_session_via_daemon(
)
from omnigent.native_terminal import bind_session_runner
try:
async def resolve_session() -> tuple[str, bool]:
"""Fork, resume, or create the session to bind, and say if it is fresh.
:returns: The session id and whether it was created just now.
:raises click.ClickException: If the server rejects the create/fork.
"""
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
try:
if fork_session_id is not None:
fork_result = await sdk.sessions.fork(fork_session_id)
session_id = fork_result["id"]
fresh_session = False
elif resume_conversation_id is not None:
session_id = resume_conversation_id
fresh_session = False
else:
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
session_id = created.id
fresh_session = True
return fork_result["id"], False
if resume_conversation_id is not None:
return resume_conversation_id, False
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
return created.id, True
except ClientOmnigentError as exc:
# Any create/fork/resume rejection here is a server-side answer, not
# a client bug worth a traceback: a wrong base URL that answers
@@ -1621,6 +1622,7 @@ async def _prepare_chat_session_via_daemon(
f"Could not start a session on {base_url}: {exc}"
) from exc
try:
# A separate raw httpx client for the host-runner protocol (the daemon
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
timeout = httpx.Timeout(30.0, read=120.0)
@@ -1629,8 +1631,11 @@ async def _prepare_chat_session_via_daemon(
) as client:
if progress is not None:
progress.update(STARTUP_PHASE_CONNECTING)
await wait_for_host_online(
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
(session_id, fresh_session), _ = await asyncio.gather(
resolve_session(),
wait_for_host_online(
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
),
)
if progress is not None:
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
+33
View File
@@ -55,6 +55,21 @@ CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
#: takes — so it is not part of the vocabulary below.
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
#: BACK-COMPAT. Omnigent's picker-row id for the custom slot. Named for the
#: model the slot first carried (Sonnet 5, which had no family alias of its
#: own), but a Smart Routing launch pins ITS model there, so the id does not
#: describe the contents — read the slot, never this name. Sessions persist
#: it as a model override, so retiring it needs a migration; slated for
#: removal in 0.10.0 along with the row, once the ``sonnet`` pin is Sonnet 5.
LEGACY_CUSTOM_SLOT_ROW_ID = "sonnet_5"
#: BACK-COMPAT. Spellings the pre-0.10 substring test read as "this is the
#: custom slot's model", kept because :func:`normalized_model_id` does not
#: fold a vendor-prefixed ``anthropic/claude-sonnet-5`` onto the catalog id
#: the slot holds. Consulted only after an exact match misses; retired with
#: :data:`LEGACY_CUSTOM_SLOT_ROW_ID` in 0.10.0.
LEGACY_CUSTOM_SLOT_SPELLINGS: tuple[str, ...] = ("sonnet-5", "sonnet_5")
#: Launch-env keys that define this session's model vocabulary.
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
*ALIAS_MODEL_ENV_VARS.values(),
@@ -162,6 +177,14 @@ def claude_model_alias(
candidate = model.strip().lower()
if candidate in CLAUDE_MODEL_ALIASES:
return candidate
# Bracket variants of the family aliases (``sonnet[1m]``) are settable
# aliases in their own right — the harness enumerates them in /model's
# usage line and resolves the marker itself (the family pin plus the
# marker on a pinned env). Stepping one down to its family would
# silently drop the marker; refusing it blocks a switch the pane accepts.
base, bracket, marker = candidate.partition("[")
if bracket and marker.endswith("]") and base in CLAUDE_MODEL_ALIASES:
return candidate
pins = alias_pins(env)
normalized = normalized_model_id(model)
for alias, pinned in pins.items():
@@ -202,4 +225,14 @@ def claude_model_command_arg(
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
if custom and normalized_model_id(custom) == normalized_model_id(model):
return custom
candidate = model.strip()
if not alias_pins(env) and candidate.lower().startswith("claude-"):
# A full Anthropic model id names an EXACT generation, and ``/model``
# on an unpinned (canonical-endpoint) session accepts full ids
# verbatim — the same spelling the harness's own enumeration
# resolves. Stepping down to the family alias here would switch to
# claude's CURRENT generation of that family instead (picking
# "Opus 4.8 (1M context)" used to type ``/model opus`` and land on
# Opus 5).
return candidate
return claude_model_alias(model, env)
+570 -53
View File
@@ -81,8 +81,11 @@ from omnigent._wrapper_labels import (
)
from omnigent.claude_launcher import resolve_claude_launch
from omnigent.claude_model_vocabulary import (
ALIAS_MODEL_ENV_VARS,
CUSTOM_MODEL_OPTION_ENV_VAR,
CUSTOM_MODEL_OPTION_NAME_ENV_VAR,
LEGACY_CUSTOM_SLOT_ROW_ID,
claude_model_alias,
)
from omnigent.claude_native_bridge import (
BRIDGE_ID_LABEL_KEY,
@@ -112,7 +115,6 @@ from omnigent.host.daemon_launch import (
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.model_fallbacks import static_model_fallback
from omnigent.native_coding_agents import native_shell_terminal_spec
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
@@ -132,7 +134,6 @@ from omnigent.native_terminal import (
from omnigent.native_terminal import (
terminal_attach_url as _attach_url,
)
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
from omnigent.terminals.ws_bridge import (
WS_CLOSE_TERMINAL_DETACHED,
WS_CLOSE_TERMINAL_NOT_FOUND,
@@ -193,6 +194,18 @@ _CLAUDE_CODE_NESTED_SESSION_ENV = "CLAUDECODE"
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV = "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"
_CLAUDE_CODE_USE_GATEWAY_ENV = "CLAUDE_CODE_USE_GATEWAY"
#: Kill-switch Claude Code treats as covering nonessential startup traffic;
#: the probe strips it so speed knobs never mask harness output.
_CLAUDE_NONESSENTIAL_TRAFFIC_ENV = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
_CLAUDE_MODEL_PROBE_TIMEOUT_S = 20.0
#: Wall-clock cap for the per-alias resolution fan-out as a whole; aliases
#: still unresolved when it expires keep their bare rows (the cache's
#: revalidation retries them later). Startup dominates each run and
#: stretches with box load (measured 0.7s17s for the same command), so the
#: concurrency covers a whole alias set in one wave and the budget fits one
#: slow wave.
_CLAUDE_ALIAS_RESOLUTION_BUDGET_S = 30.0
_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY = 12
_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
_CLAUDE_CODE_CUSTOM_HEADERS_ENV = "ANTHROPIC_CUSTOM_HEADERS"
# Claude Code forwards the ANTHROPIC_CUSTOM_HEADERS value verbatim as
@@ -233,15 +246,8 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
# See https://code.claude.com/docs/en/model-config#custom-model-options
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = CUSTOM_MODEL_OPTION_ENV_VAR
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = CUSTOM_MODEL_OPTION_NAME_ENV_VAR
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
_UCODE_CLAUDE_CUSTOM_TIER = LEGACY_CUSTOM_SLOT_ROW_ID
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
_CLAUDE_NATIVE_STATIC_MODEL_OPTIONS: tuple[tuple[str, str], ...] = (
("fable", "Fable"),
("opus", "Opus"),
("sonnet", "Sonnet 4.6"),
(_UCODE_CLAUDE_CUSTOM_TIER, _UCODE_CLAUDE_CUSTOM_TIER_LABEL),
("haiku", "Haiku"),
)
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
_SESSION_LABELS = {
"omnigent.ui": "terminal",
@@ -409,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
return host == "anthropic.com" or host.endswith(".anthropic.com")
def _claude_family(token: str) -> str | None:
"""
The family alias a model id or alias folds onto, bracket markers dropped.
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
``"claude-opus-4-8"``.
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
"""
from omnigent.claude_model_vocabulary import claude_model_alias
alias = claude_model_alias(token, {})
return alias.partition("[")[0] if alias else None
def claude_catalog_serves_model(
rows: list[dict[str, object]],
model: str,
claude_config: ClaudeNativeUcodeConfig | None,
) -> bool:
"""
Whether a launch of *model* is backed by this config's catalog.
An exact row — a picker id or its wire model — always serves. A canonical
Anthropic id no row spells exactly still launches when the endpoint takes
canonical spellings (``--model`` passes any string through, and a pane's
``/model`` persists exactly this id) and the catalog lists the id's
family: the same family fold ``/model`` applies to an unpinned canonical
id. A gateway that routes only its own ids, and a family the catalog
does not list, refuse — a genuinely stale pick still fails fast.
:param rows: Catalog rows, e.g.
``[{"id": "opus", "model": "claude-opus-5"}]``.
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
:param claude_config: The resolved launch config, or ``None`` (Claude's
own login).
:returns: ``True`` when the launch can run *model* against this catalog.
"""
from omnigent.model_catalog_store import catalog_contains
if catalog_contains(rows, model):
return True
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
return False
if not model.lower().startswith("claude-"):
return False
family = _claude_family(model)
return family is not None and any(
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
)
def resolve_claude_native_model_selection(
model: str | None,
claude_config: ClaudeNativeUcodeConfig | None,
@@ -419,28 +476,21 @@ def resolve_claude_native_model_selection(
extra Sonnet 5 row uses Omnigent's ``sonnet_5`` id because it occupies
Claude Code's provider-configured custom model slot. Resolve that id to
the exact custom option, preserving provider suffixes such as ``[1m]``.
Direct Claude logins have no provider config, so they use the canonical
Anthropic model id.
Direct Claude logins have no provider config, so the pick degrades to
the ``sonnet`` family alias, which Claude resolves itself.
On a gateway/Bedrock endpoint, a family alias with no tier pin (launch env
or managed settings) resolves to the provider's default model — Claude
Code would canonicalize it to an Anthropic id the endpoint rejects.
Every other pick passes through verbatim: picker rows are pin-backed or
vouched for by the harness's own probe, so rewriting one switches the
pane to a model nobody chose (an unpinned family alias used to degrade
to the provider's default model this way — a Fable pick landed on
Opus). An out-of-band unpinned alias on a gateway endpoint now fails
visibly at inference instead of silently running the default.
:param model: Persisted picker id, built-in alias, or concrete model id.
:param claude_config: Resolved provider config for the terminal.
:returns: A model identifier suitable for ``--model`` or ``/model``.
"""
if model != _UCODE_CLAUDE_CUSTOM_TIER:
tier_env = _UCODE_CLAUDE_TIER_TO_ENV.get(model or "")
if (
tier_env is not None
and claude_config is not None
and not claude_config.env.get(tier_env)
and not _serves_canonical_anthropic_ids(claude_config)
):
managed = _managed_claude_model_config()
if managed is None or not managed.env.get(tier_env):
return claude_config.model or model
return model
if claude_config is not None:
custom_model = claude_config.env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
@@ -451,26 +501,9 @@ def resolve_claude_native_model_selection(
return provider_fallback
if claude_config.model:
return claude_config.model
fallback = static_model_fallback(SUBSCRIPTION_KIND, "claude")
if fallback is None:
raise ValueError("Claude subscription fallback has no routable Sonnet model")
exact_match = next(
(
model_id
for model_id in fallback.model_ids
if _claude_model_display_name("sonnet", model_id) == _UCODE_CLAUDE_CUSTOM_TIER_LABEL
),
None,
)
if exact_match is not None:
return exact_match
family_match = next(
(model_id for model_id in fallback.model_ids if "claude-sonnet-" in model_id.lower()),
None,
)
if family_match is None:
raise ValueError("Claude subscription fallback has no routable Sonnet model")
return family_match
# No provider config pins the custom slot: hand Claude Code its own
# ``sonnet`` alias and let it resolve the current Sonnet itself.
return "sonnet"
def claude_config_with_routed_arms_pinned(
@@ -705,15 +738,472 @@ def claude_native_model_options(
if not model_id:
return []
return [{"id": model_id, "model": model_id, "displayName": model_id, "isDefault": True}]
return [
{
"id": model_id,
"model": model_id,
"displayName": label,
"isDefault": False,
}
for model_id, label in _CLAUDE_NATIVE_STATIC_MODEL_OPTIONS
# No curated fallback: an unconfigured shape's rows come from the probe
# (the harness's own enumeration) or not at all — a hand-written list
# here is exactly how a frozen "Sonnet 4.6" once shipped.
return []
def _parse_claude_model_aliases(stdout: str) -> list[str]:
"""
Extract the alias list from ``claude -p "/model"``'s printed usage line.
The harness prints e.g. ``Usage: /model <name>. Available: sonnet, opus,
haiku, fable, best, sonnet[1m], opusplan, default, or a full model ID.``
— its own enumeration of every settable alias. Parsing keeps zero model
knowledge here: entries are taken verbatim, and only the trailing prose
fragment (anything with whitespace) is dropped.
:param stdout: The probe run's stdout.
:returns: Alias tokens in the harness's order; empty when no line parses.
"""
for line in stdout.splitlines():
_, marker, tail = line.partition("Available:")
if not marker:
continue
aliases: list[str] = []
for entry in tail.split(","):
token = entry.strip().rstrip(".")
if token and " " not in token:
aliases.append(token)
return aliases
return []
def _parse_claude_current_model(stdout: str) -> dict[str, str]:
"""
Extract the resolved model from a stream-json ``/model`` probe run.
Two harness-owned facts, taken verbatim: the ``init`` event's exact
model id, and the printed ``Current model:`` label with only the
trailing ``(effort: …)`` suffix stripped — so labels like
``Opus 4.8 (1M context)`` survive untouched.
:param stdout: The run's ``--output-format stream-json`` stdout.
:returns: Whichever of ``{"model": …, "label": …}`` parsed.
"""
resolved: dict[str, str] = {}
for line in stdout.splitlines():
try:
event = json.loads(line)
except ValueError:
continue
if not isinstance(event, dict):
continue
if event.get("type") == "system" and event.get("subtype") == "init":
model = event.get("model")
if isinstance(model, str) and model:
resolved["model"] = model
if event.get("type") == "result":
for text_line in str(event.get("result", "")).splitlines():
_, marker, tail = text_line.partition("Current model:")
if not marker:
continue
label = re.sub(r"\s*\(effort:[^)]*\)\s*$", "", tail).strip()
if label:
resolved["label"] = label
break
return resolved
def _claude_model_probe_invocation(
claude_config: ClaudeNativeUcodeConfig | None,
extra_args: Sequence[str] = (),
) -> tuple[str, list[str], dict[str, str]]:
"""
Assemble one headless ``/model`` probe invocation.
Shared by the alias-enumeration run and the per-alias resolution runs
so the two cannot drift: same launch resolution, session env, speed
env, and env-unset list.
:param claude_config: The resolved native launch config, or ``None``.
:param extra_args: Appended CLI args (e.g. ``--model <alias>``).
:returns: ``(command, launch_args, env)`` ready to exec.
"""
from omnigent.claude_launcher import resolve_claude_launch
args = [
"-p",
"/model",
# The probe asks one client-side question; the MCP fleet, session
# persistence, and background chatter are irrelevant startup weight.
"--strict-mcp-config",
"--mcp-config",
'{"mcpServers":{}}',
"--no-session-persistence",
*extra_args,
]
if claude_config is not None and claude_config.api_key_helper:
args.extend(("--settings", json.dumps({"apiKeyHelper": claude_config.api_key_helper})))
command, launch_args = resolve_claude_launch("claude", args)
env = dict(os.environ)
env.update(build_native_claude_terminal_env(claude_config))
env.update(
{
"DISABLE_TELEMETRY": "1",
"DISABLE_ERROR_REPORTING": "1",
"DISABLE_AUTOUPDATER": "1",
}
)
# Mirrors the native terminal's env-unset list, plus the nonessential-
# traffic kill-switch, so speed knobs never mask harness output.
env.pop("DATABRICKS_CONFIG_PROFILE", None)
env.pop(_CLAUDE_CODE_NESTED_SESSION_ENV, None)
env.pop(_CLAUDE_NONESSENTIAL_TRAFFIC_ENV, None)
if claude_config is not None and claude_config.api_key_helper:
env.pop(_ANTHROPIC_API_KEY_ENV, None)
return command, launch_args, env
async def _resolve_claude_model_alias(
claude_config: ClaudeNativeUcodeConfig | None,
alias: str,
) -> dict[str, str]:
"""
Ask the harness what one printed alias resolves to.
A ``--model <alias>`` run in stream-json mode carries the exact model
id in its init event and the human label in its ``Current model:``
line; both are the harness's own resolution, never computed here.
:param claude_config: The resolved native launch config, or ``None``.
:param alias: The alias exactly as the harness printed it.
:returns: Whichever of ``{"model": …, "label": …}`` resolved; empty on
any failure (the alias keeps its bare row).
"""
command, launch_args, env = _claude_model_probe_invocation(
claude_config,
("--model", alias, "--output-format", "stream-json", "--verbose"),
)
try:
process = await asyncio.create_subprocess_exec(
command,
*launch_args,
cwd=str(Path.home()),
env=env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError:
_logger.debug("Claude alias resolution could not launch for %r", alias, exc_info=True)
return {}
try:
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
stdout, _stderr = await process.communicate()
except (TimeoutError, asyncio.CancelledError) as exc:
if process.returncode is None:
process.kill()
with contextlib.suppress(Exception):
await process.wait()
if isinstance(exc, asyncio.CancelledError):
raise
_logger.debug("Claude alias resolution timed out for %r", alias)
return {}
if process.returncode != 0:
_logger.debug("Claude alias resolution exited %s for %r", process.returncode, alias)
return {}
return _parse_claude_current_model(stdout.decode(errors="replace"))
async def _resolve_claude_model_aliases(
claude_config: ClaudeNativeUcodeConfig | None,
aliases: Sequence[str],
) -> dict[str, dict[str, str]]:
"""
Resolve every printed alias concurrently, best-effort.
Bounded fan-out under one overall budget: whatever resolved in time is
kept and the rest stay bare, so a hung harness cannot stretch the
probe indefinitely.
:param claude_config: The resolved native launch config, or ``None``.
:param aliases: The harness-printed aliases.
:returns: Non-empty resolutions keyed by alias.
"""
if not aliases:
return {}
semaphore = asyncio.Semaphore(_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY)
async def _bounded(alias: str) -> dict[str, str]:
async with semaphore:
return await _resolve_claude_model_alias(claude_config, alias)
tasks = {alias: asyncio.create_task(_bounded(alias)) for alias in aliases}
try:
async with asyncio.timeout(_CLAUDE_ALIAS_RESOLUTION_BUDGET_S):
await asyncio.gather(*tasks.values())
except TimeoutError:
for task in tasks.values():
task.cancel()
await asyncio.gather(*tasks.values(), return_exceptions=True)
done = sum(1 for task in tasks.values() if not task.cancelled())
_logger.warning(
"Claude alias resolution budget expired; keeping %d/%d resolutions",
done,
len(tasks),
)
return {
alias: task.result()
for alias, task in tasks.items()
if not task.cancelled() and task.exception() is None and task.result()
}
def _claude_alias_row(alias: str, resolution: dict[str, str]) -> dict[str, object]:
"""
One picker row for a printed alias, shown as its resolution.
``id`` stays the alias — launches pass it through unchanged — while the
display shows only the resolved label. Labels are normalized so every
1M-context resolution (a ``[1m]``-suffixed model id) says so; the
harness omits the marker for some of them (e.g. ``sonnet[1m]`` prints
just "Sonnet 5").
:param alias: The harness-printed alias.
:param resolution: Its resolution, possibly empty.
:returns: The picker row.
"""
label = resolution.get("label")
model = resolution.get("model") or alias
if label and model.endswith("[1m]") and "1M context" not in label:
label = f"{label} (1M context)"
return {"id": alias, "model": model, "displayName": label or alias}
@dataclass(frozen=True)
class ClaudeModelProbe:
"""One harness enumeration: picker rows plus the bare-launch default.
:param alias_rows: The printed aliases as deduplicated picker rows.
:param default_model: The model the enumeration run itself launched on
(its init event's ``model``) — what a no-pick launch of this config
actually runs — or ``None`` when unreadable.
:param default_label: The harness's own label for *default_model*, or
``None``.
"""
alias_rows: list[dict[str, object]]
default_model: str | None = None
default_label: str | None = None
def _parse_claude_enumeration_aliases(stdout: str) -> list[str]:
"""Extract the alias list from a stream-json enumeration run.
The ``Available:`` line lives inside the ``result`` event's text on a
stream-json run; falls back to scanning the raw output so a plain-text
run still parses.
:param stdout: The enumeration run's decoded stdout.
:returns: Alias names, e.g. ``["sonnet", "opus", ...]``.
"""
for line in stdout.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
try:
event = json.loads(line)
except ValueError:
continue
if isinstance(event, dict) and event.get("type") == "result":
result_text = event.get("result")
if isinstance(result_text, str):
aliases = _parse_claude_model_aliases(result_text)
if aliases:
return aliases
return _parse_claude_model_aliases(stdout)
async def probe_claude_model_options(
claude_config: ClaudeNativeUcodeConfig | None,
) -> ClaudeModelProbe | None:
"""
Ask Claude Code itself which models it would offer, and its default.
The harness is the source of truth: a short ``claude -p "/model"`` run
(stream-json, so its init event also names the model a bare launch of
this config actually runs — the truthful "Default") makes Claude Code
print its own alias list. Each printed alias is then resolved to its
concrete model by a per-alias harness run. All outputs are read
verbatim; no selection semantics are replicated here. Runs for every
config shape, including the bare subscription launch (``None`` config).
:param claude_config: The resolved native launch config
(:func:`resolve_native_claude_config`), or ``None``.
:returns: The probe result, or ``None`` when the probe failed (callers
fall back to the configured/static rows).
"""
command, launch_args, env = _claude_model_probe_invocation(
claude_config, ("--output-format", "stream-json", "--verbose")
)
try:
process = await asyncio.create_subprocess_exec(
command,
*launch_args,
cwd=str(Path.home()),
env=env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError:
_logger.warning("Claude model probe could not launch the claude CLI", exc_info=True)
return None
try:
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
stdout, stderr = await process.communicate()
except (TimeoutError, asyncio.CancelledError):
if process.returncode is None:
process.kill()
with contextlib.suppress(Exception):
await process.wait()
_logger.warning("Claude model probe timed out; keeping configured rows only")
return None
if process.returncode != 0:
_logger.warning(
"Claude model probe exited %s: %s",
process.returncode,
stderr.decode(errors="replace").strip()[-500:],
)
return None
text = stdout.decode(errors="replace")
aliases = _parse_claude_enumeration_aliases(text)
# The enumeration run's own init event names what a bare launch runs —
# the harness's truthful Default.
default_resolution = _parse_claude_current_model(text)
# The picker renders its own top-level Default choice (launch with no
# model), which is exactly what the harness's ``default`` alias does —
# listing it again would duplicate that row.
aliases = [alias for alias in aliases if alias != "default"]
resolutions = await _resolve_claude_model_aliases(claude_config, aliases)
alias_rows: list[dict[str, object]] = []
seen_models: set[object] = set()
for alias in aliases:
row = _claude_alias_row(alias, resolutions.get(alias, {}))
# Distinct aliases can resolve to a model an earlier row already
# covers (``best``, ``fable[1m]``, and ``opusplan`` all do today);
# repeating the model is picker noise.
if row["model"] in seen_models:
continue
seen_models.add(row["model"])
alias_rows.append(row)
return ClaudeModelProbe(
alias_rows=alias_rows,
default_model=default_resolution.get("model"),
default_label=default_resolution.get("label"),
)
def claude_catalog_fingerprint(claude_config: ClaudeNativeUcodeConfig | None) -> str:
"""The launch-config fingerprint keying claude's shared model catalog.
One formula for every consumer (host boot probe, runner launch, session
listing), so they read and write the same catalog file.
:param claude_config: The resolved launch config, or ``None``.
:returns: A stable fingerprint string.
"""
from omnigent.model_catalog_store import fingerprint_of
return fingerprint_of(
"claude-native",
sorted(claude_config.env.items()) if claude_config is not None else None,
claude_config.api_key_helper if claude_config is not None else None,
claude_config.model if claude_config is not None else None,
)
async def claude_model_catalog(
claude_config: ClaudeNativeUcodeConfig | None,
) -> list[dict[str, object]] | None:
"""
The harness-truth catalog: probe rows with one truthful ``isDefault``.
Rows come from the harness's own enumeration alone (no configured/static
merge). Servability filtering matches the listing composition: on a
non-canonical endpoint, aliases resolving to bare Anthropic ids are
dropped. The default marker is what a Default launch of this config
actually runs: the config's own launch pin when the provider resolves
one (those launches pass ``--model`` explicitly), else the enumeration
run's init-event model (a bare subscription launch). It is matched onto
its row, or appended as its own row when the catalog lacks it (a
``settings.json`` pin, say) and the endpoint can serve it.
:param claude_config: The resolved launch config, or ``None``.
:returns: Catalog rows, or ``None`` when the probe failed.
"""
probe = await probe_claude_model_options(claude_config)
if probe is None:
return None
rows = list(probe.alias_rows)
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
rows = [row for row in rows if not str(row.get("model", "")).startswith("claude-")]
configured_pin = claude_config.model if claude_config is not None else None
default_model = configured_pin or probe.default_model
marked = False
out: list[dict[str, object]] = []
for row in rows:
is_default = (
bool(default_model)
and not marked
and (row.get("model") == default_model or row.get("id") == default_model)
)
if is_default:
marked = True
out.append({**row, "isDefault": True})
else:
out.append({key: value for key, value in row.items() if key != "isDefault"})
if default_model and not marked:
# Append the observed default as its own honest row — but never
# claim a bare Anthropic id is launchable on an endpoint that
# rejects that spelling.
servable = (
claude_config is None
or _serves_canonical_anthropic_ids(claude_config)
or not default_model.startswith("claude-")
)
if servable:
# The probe's printed label describes the ENUMERATION run's
# model; it only names a config-pinned default when the two are
# the same model.
label = (
probe.default_label
if default_model == probe.default_model and probe.default_label
else default_model
)
out.append(
{
"id": default_model,
"model": default_model,
"displayName": label,
"isDefault": True,
}
)
return out
async def claude_launch_catalog(
claude_config: ClaudeNativeUcodeConfig | None,
) -> list[dict[str, object]] | None:
"""
The shared catalog for this launch config: read the store, probe on miss.
The store read is what keeps launches fast once the host's boot probe
(or a previous launch) has run; a cold miss pays one probe and persists
the answer for every later consumer.
:param claude_config: The resolved launch config, or ``None``.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
from omnigent import model_catalog_store
fingerprint = claude_catalog_fingerprint(claude_config)
return await model_catalog_store.ensure_catalog(
"claude-native", fingerprint, lambda: claude_model_catalog(claude_config)
)
def build_native_claude_terminal_env(
@@ -2234,9 +2724,27 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
family.base_url,
family.default_model,
)
# Pin the alias vocabulary to the entry's declared models, so ``/model``
# and the harness probe resolve aliases inside this gateway's routable
# set instead of falling back to canonical Anthropic ids the gateway
# rejects. The ``models:`` map's flat tier keys (``opus``/``sonnet``/…)
# pin their aliases directly; ``models.default`` pins its own family's
# alias when nothing else declared that family.
pin_env: dict[str, str] = {}
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
pinned = family.models.get(alias)
if isinstance(pinned, str) and pinned.strip():
pin_env[env_var] = pinned.strip()
if family.default_model:
default_alias = claude_model_alias(family.default_model, env={})
base_alias = (default_alias or "").partition("[")[0]
default_env_var = ALIAS_MODEL_ENV_VARS.get(base_alias)
if default_env_var:
pin_env.setdefault(default_env_var, family.default_model)
return ClaudeNativeUcodeConfig(
env={
_UCODE_CLAUDE_BASE_URL_ENV: family.base_url,
**pin_env,
# Disable beta flags gateways reject (400 "invalid beta flag");
# skip when CLAUDE_CODE_USE_GATEWAY=1 to keep tool search enabled.
**(
@@ -2247,6 +2755,15 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
},
api_key_helper=api_key_helper,
model=family.default_model,
# The declared models are exactly what this entry can route.
routable_models=tuple(
dict.fromkeys(
[
*pin_env.values(),
*([family.default_model] if family.default_model else []),
]
)
),
)
+323 -11
View File
@@ -179,6 +179,27 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
# whether the draft is rendered in the input box. Short enough to fit
# on the prompt row of a default 80-column detached pane.
_DRAFT_NEEDLE_MAX_CHARS = 24
# Mode footer Claude Code renders below its input box, keyed by
# ``--permission-mode`` value. There is no non-interactive mode command, so
# a live switch cycles with shift+tab and reads this footer to know where it
# landed. The prompting mode is ``default`` on the CLI, "manual" on screen.
_PERMISSION_MODE_FOOTERS: dict[str, str] = {
"default": "manual mode on",
"acceptEdits": "accept edits on",
"plan": "plan mode on",
"auto": "auto mode on",
}
# Modes shift+tab can reach. ``dontAsk`` is never in the cycle and
# ``bypassPermissions`` only joins it when launched into, so both are
# rejected up front.
CYCLEABLE_PERMISSION_MODES = frozenset(_PERMISSION_MODE_FOOTERS)
# Cap on shift+tab presses. The cycle is 3-5 modes wide depending on which
# optional modes are enabled, so a full lap plus slack proves the target is
# unreachable rather than slow.
_MODE_CYCLE_MAX_PRESSES = 8
# Wait for the footer to repaint after a shift+tab before reading it.
_MODE_FOOTER_SETTLE_TIMEOUT_S = 2.0
_MODE_FOOTER_POLL_INTERVAL_S = 0.1
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
# picker the person opened by hand covers the input box, so an injection would
@@ -253,6 +274,14 @@ _INVOCATION_SETTINGS_FILE = "claude-settings.json"
ToolExecutor = Callable[[str, _JsonObject], Awaitable[object]]
class ClaudePromptTimeout(RuntimeError):
"""Claude Code's input box did not render before delivery timed out."""
class TmuxSessionNotAdvertised(RuntimeError):
"""The bridge's tmux target was not advertised before the deadline."""
def _absolute_syntactic_path(path: Path) -> Path:
"""
Return an absolute path without following symlinks.
@@ -446,6 +475,11 @@ class TranscriptReadResult:
entry was scanned.
:param latest_model: ``message.model`` from the most recent
assistant entry, or ``None``.
:param latest_custom_title: ``customTitle`` from the most recent
``custom-title`` record the explicit title a ``/rename`` typed
in the Claude Code pane writes. ``None`` when no such record was
scanned. Claude's own auto-generated ``aiTitle`` is deliberately
not surfaced here; Omnigent titles unnamed sessions itself.
"""
line_cursor: int
@@ -454,6 +488,7 @@ class TranscriptReadResult:
items: list[ClaudeTranscriptItem]
latest_usage: dict[str, int] | None = None
latest_model: str | None = None
latest_custom_title: str | None = None
@dataclass(frozen=True)
@@ -1213,6 +1248,48 @@ def read_model_env(bridge_dir: Path) -> dict[str, str]:
}
def record_model_vocabulary(
bridge_dir: Path,
*,
launch_env: Mapping[str, str] | None,
launch_model: str | None,
) -> None:
"""
Persist the launch's model vocabulary after the bridge dir exists.
The runner prepares the bridge before it resolves the provider config,
so the vocabulary (alias pins + custom slot) and the launch model land
here in a second write once known the same keys
:func:`prepare_bridge_dir` records on the CLI path, so
:func:`read_model_env` / :func:`read_launch_model` serve both paths
identically.
:param bridge_dir: Bridge directory path.
:param launch_env: The resolved launch env (pins + custom slot), or
``None`` for a bare subscription launch.
:param launch_model: The model the launch pins via ``--model``, or
``None``.
:returns: None.
"""
config = _read_json_file(bridge_dir / _CONFIG_FILE)
if not isinstance(config, dict):
return
model_env = {
key: launch_env[key]
for key in MODEL_VOCABULARY_ENV_VARS
if launch_env is not None and launch_env.get(key)
}
changed = False
if model_env and config.get("model_env") != model_env:
config["model_env"] = model_env
changed = True
if launch_model and config.get("launch_model") != launch_model:
config["launch_model"] = launch_model
changed = True
if changed:
_write_json_file(bridge_dir / _CONFIG_FILE, config)
def read_bridge_id(bridge_dir: Path) -> str | None:
"""
Read the opaque bridge id from bridge config.
@@ -2194,11 +2271,14 @@ def read_transcript_items_since(
Claude Code writes append-only JSONL records whose ``message``
payloads include user prompts, assistant text, native tool calls,
and native tool results. This parser intentionally ignores
metadata records (title, file-history, permission mode, system
bookkeeping) and raw ``thinking`` blocks, while translating the
user-visible semantic records into Omnigent item types the web UI
already understands.
and native tool results. This parser intentionally renders no
conversation item for metadata records (title, file-history,
permission mode, system bookkeeping) or raw ``thinking`` blocks,
while translating the user-visible semantic records into Omnigent
item types the web UI already understands. Some metadata is still
read for out-of-band mirroring rather than dropped outright a
``custom-title`` record surfaces on
:attr:`TranscriptReadResult.latest_custom_title`.
:param transcript_path: Claude transcript path, e.g.
``"/home/user/.claude/projects/x/session.jsonl"``.
@@ -2259,6 +2339,7 @@ def read_transcript_items_since_with_position(
active_settled_id = settled_response_id
latest_usage: dict[str, int] | None = None
latest_model: str | None = None
latest_custom_title: str | None = None
for record in read_result.records:
if record.text is None:
continue
@@ -2288,6 +2369,9 @@ def read_transcript_items_since_with_position(
model = _model_from_transcript_entry(entry)
if model is not None:
latest_model = model
custom_title = _custom_title_from_transcript_entry(entry)
if custom_title is not None:
latest_custom_title = custom_title
return TranscriptReadResult(
line_cursor=read_result.line_cursor,
byte_offset=read_result.byte_offset,
@@ -2295,6 +2379,7 @@ def read_transcript_items_since_with_position(
items=items,
latest_usage=latest_usage,
latest_model=latest_model,
latest_custom_title=latest_custom_title,
)
@@ -2347,6 +2432,7 @@ def read_transcript_items_from_offset(
active_settled_id = settled_response_id
latest_usage: dict[str, int] | None = None
latest_model: str | None = None
latest_custom_title: str | None = None
for record in read_result.records:
if record.text is None:
continue
@@ -2377,6 +2463,9 @@ def read_transcript_items_from_offset(
model = _model_from_transcript_entry(entry)
if model is not None:
latest_model = model
custom_title = _custom_title_from_transcript_entry(entry)
if custom_title is not None:
latest_custom_title = custom_title
return TranscriptReadResult(
line_cursor=read_result.line_cursor,
byte_offset=read_result.byte_offset,
@@ -2384,6 +2473,7 @@ def read_transcript_items_from_offset(
items=items,
latest_usage=latest_usage,
latest_model=latest_model,
latest_custom_title=latest_custom_title,
)
@@ -3168,10 +3258,16 @@ def kill_session(
there is no live session to kill.
:returns: None.
:raises RuntimeError: If the tmux target is not advertised in
time, or if the ``tmux kill-session`` invocation fails.
time, or if ``tmux kill-session`` fails for an unexpected reason.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
try:
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
except RuntimeError as exc:
detail = str(exc).lower()
if "can't find session" in detail or "no server running on" in detail:
return
raise
def inject_slash_command(
@@ -3337,6 +3433,171 @@ def _confirm_tui_dialog(
return False
def _permission_mode_from_pane(pane: str) -> str | None:
"""
Read Claude Code's current permission mode off a captured pane.
The footer (`` auto mode on``, `` plan mode on``, ...) always sits
below the input box's closing rule, so the scan starts there rather than
at a fixed offset from the bottom: the footer's height scales with
concurrent subagents, which a fixed window cannot bound (the same reason
:func:`_claude_prompt_rendered` anchors on :func:`_is_box_rule`). Anchoring
also excludes transcript text structurally a mode name Claude echoed
while *discussing* modes sits above the box and can't be misread as live.
:param pane: Captured pane text from :func:`_capture_pane`.
:returns: The ``--permission-mode`` value for the rendered footer,
e.g. ``"auto"``, or ``None`` when no footer is visible (the
pane is mid-repaint, or the mode is one with no footer).
"""
lines = [line for line in pane.splitlines() if line.strip()]
# Below the last box rule is the footer region. With no rule the input box
# isn't mounted; fall back to the tail so a footer still reads during boot.
last_rule = max((i for i, line in enumerate(lines) if _is_box_rule(line)), default=None)
region = lines[last_rule + 1 :] if last_rule is not None else lines[-_PROMPT_SCAN_TAIL_LINES:]
for line in reversed(region):
for mode, footer in _PERMISSION_MODE_FOOTERS.items():
if footer in line:
return mode
return None
def _read_settled_permission_mode(
socket_path: str,
tmux_target: str,
*,
previous: str | None = None,
) -> str | None:
"""
Poll the pane until its permission-mode footer settles.
A shift+tab repaints the footer asynchronously, so an immediate
capture can read nothing or, worse, still read the PREVIOUS mode
and make the cycler believe the keystroke did nothing. Passing
*previous* waits for the footer to actually change.
:param socket_path: Absolute path to the tmux socket.
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:param previous: Mode read before the keystroke that prompted this
read, e.g. ``"plan"``. The pane keeps rendering it until the TUI
repaints, so a read that returns it is treated as not-yet-settled
and retried; ``None`` accepts the first mode seen (the initial
read, where there is nothing to change from).
:returns: The rendered mode, or ``None`` if none appeared or the
footer never moved off *previous* before
:data:`_MODE_FOOTER_SETTLE_TIMEOUT_S`.
"""
deadline = time.monotonic() + _MODE_FOOTER_SETTLE_TIMEOUT_S
while True:
mode = _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
if mode is not None and mode != previous:
return mode
if time.monotonic() >= deadline:
# Timed out: report the last mode seen so a pane that legitimately
# stayed put is distinguished from one with no footer at all.
return mode
time.sleep(_MODE_FOOTER_POLL_INTERVAL_S)
def set_permission_mode(
bridge_dir: Path,
*,
mode: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> str:
"""
Switch the running Claude terminal to *mode* by cycling shift+tab.
Claude Code has no non-interactive way to set a live session's mode
(``--permission-mode`` is launch-only, ``/permissions`` is an interactive
dialog, settings load at startup), so this drives the TUI's shift+tab
cycle, reading the mode footer after each press. The cycle is walked
rather than computed: its width varies with which optional modes are
enabled, so a fixed press count could land on the wrong mode.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:param mode: Target ``--permission-mode`` value, one of
:data:`CYCLEABLE_PERMISSION_MODES`, e.g. ``"auto"``.
:param timeout_s: Seconds to wait for ``tmux.json`` to be
advertised by the runner, e.g. ``30.0``.
:returns: The mode now rendered in the pane (== *mode*).
:raises ValueError: If *mode* is not cycle-reachable.
:raises RuntimeError: If the tmux target is not advertised in time,
a ``tmux`` invocation fails, the pane never renders a mode
footer, or the target is not reached within
:data:`_MODE_CYCLE_MAX_PRESSES` presses (the mode is not in
this session's cycle).
"""
if mode not in CYCLEABLE_PERMISSION_MODES:
raise ValueError(
f"permission mode {mode!r} cannot be switched on a running session; "
f"expected one of {sorted(CYCLEABLE_PERMISSION_MODES)}"
)
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
# The footer only renders once the input box is mounted; without
# this gate a shift+tab sent mid-boot is dropped and the read below
# reports a mode the keystroke never reached.
_wait_for_claude_prompt_ready(socket_path, tmux_target, timeout_s=timeout_s)
current = _read_settled_permission_mode(socket_path, tmux_target)
if current is None:
pane = _capture_pane(socket_path, tmux_target)
raise RuntimeError(
"Claude Code did not render a permission-mode footer, so its current "
f"mode could not be read.{_format_terminal_failure_tail(pane)}"
)
seen = [current]
for _ in range(_MODE_CYCLE_MAX_PRESSES):
if current == mode:
return current
# No ``-l``: tmux must interpret ``BTab`` as the shift+tab key.
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "BTab")
settled = _read_settled_permission_mode(socket_path, tmux_target, previous=current)
if settled is not None:
current = settled
seen.append(current)
if current == mode:
return current
raise RuntimeError(
f"Could not switch Claude Code to {mode!r} mode: cycled shift+tab "
f"{_MODE_CYCLE_MAX_PRESSES} times and only reached {sorted(set(seen))}. "
"The mode is not available in this session's cycle."
)
def confirm_dialog_if_open(bridge_dir: Path, *, hint: str) -> bool:
"""
Accept the *hint* dialog iff it is on screen RIGHT NOW; never blind-Enter.
Loop-safe building block for watchers that outlive a single injection
(a mid-turn ``/model`` queues in Claude's composer and pops its confirm
dialog only when the turn settles minutes later). Unlike
:func:`_confirm_tui_dialog` there is no timeout fallback Enter, so
calling this every few seconds can never type into a surface that is
not the named dialog.
:param bridge_dir: Bridge directory path.
:param hint: Text the dialog renders, e.g.
:data:`SWITCH_MODEL_DIALOG_HINT`.
:returns: ``True`` when the dialog was on screen and confirmed.
"""
try:
info = _wait_for_tmux_info(bridge_dir, timeout_s=1.0)
except (RuntimeError, OSError):
return False
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
try:
pane = _capture_pane(socket_path, tmux_target)
if hint not in pane:
return False
_confirm_and_verify_dialog_closed(socket_path, tmux_target, hint=hint)
except (RuntimeError, OSError):
return False
return True
def _confirm_and_verify_dialog_closed(
socket_path: str,
tmux_target: str,
@@ -3827,7 +4088,7 @@ def _wait_for_claude_prompt_ready(
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
:returns: None.
:raises RuntimeError: If the prompt never renders within
:raises ClaudePromptTimeout: If the prompt never renders within
*timeout_s* (Claude failed to boot). The message carries a poll
count, how many of those polls saw an empty capture, and the tail
of the last non-empty capture the loop actually observed (see
@@ -3864,7 +4125,7 @@ def _wait_for_claude_prompt_ready(
# session is alive but capture-pane came back blank); non-empty captures
# with no box point at Claude never rendering the prompt (a boot crash,
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
raise RuntimeError(
raise ClaudePromptTimeout(
f"Claude Code terminal did not become ready within {timeout_s}s "
f"(input prompt never rendered in {polls} polls, "
f"{empty_polls} empty captures). The message was not delivered."
@@ -3920,7 +4181,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
:param bridge_dir: Bridge directory path.
:param timeout_s: Seconds to wait, e.g. ``30.0``.
:returns: ``{"socket_path": ..., "tmux_target": ...}``.
:raises RuntimeError: If the file never appears with valid
:raises TmuxSessionNotAdvertised: If the file never appears with valid
``socket_path`` and ``tmux_target`` fields.
"""
deadline = time.monotonic() + timeout_s
@@ -3932,7 +4193,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
if isinstance(socket_path, str) and isinstance(tmux_target, str):
return {"socket_path": socket_path, "tmux_target": tmux_target}
time.sleep(0.05)
raise RuntimeError(
raise TmuxSessionNotAdvertised(
"Claude terminal tmux target is not advertised yet. Wait for the "
"terminal to launch before sending messages from the web UI."
)
@@ -4993,6 +5254,32 @@ def _model_from_transcript_entry(entry: _JsonObject) -> str | None:
return None
def _custom_title_from_transcript_entry(entry: _JsonObject) -> str | None:
"""
Return ``customTitle`` from a ``custom-title`` transcript record.
Claude Code appends this metadata record when the operator renames
the session from the pane (``/rename``). It carries no ``message``,
so it renders no conversation item; the forwarder mirrors it onto the
Omnigent session title instead.
Only the explicit user title is read. Claude also writes an
``aiTitle`` record holding its own generated summary, which is
ignored here because Omnigent runs its own background titler and two
auto-titlers would fight over one field.
:param entry: One decoded transcript JSONL record.
:returns: The operator-chosen title, e.g. ``"auth-refactor"``, or
``None`` for other record types and blank values.
"""
if entry.get("type") != "custom-title":
return None
title = entry.get("customTitle")
if isinstance(title, str) and title.strip():
return title
return None
def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
"""
Read the most recent statusLine snapshot from ``context.json``.
@@ -5027,6 +5314,31 @@ def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
return parsed
def read_permission_mode(bridge_dir: Path) -> str | None:
"""
Read the permission mode currently rendered in the Claude pane.
Non-blocking and best-effort: the forwarder calls this every poll so an
in-pane shift+tab switch reaches the web UI, which otherwise never sees it
(only UI-driven switches stamp the mode label). Returns ``None`` when the
terminal isn't up or the pane shows no mode footer, so a caller can treat
"unknown" as "no fresh observation" rather than a change.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:returns: The ``--permission-mode`` value rendered in the pane, e.g.
``"auto"``, or ``None`` when it cannot be determined.
"""
payload = _read_json_file(bridge_dir / _TMUX_FILE)
if not isinstance(payload, dict):
return None
socket_path = payload.get("socket_path")
tmux_target = payload.get("tmux_target")
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
return None
return _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
def read_claude_status_model(bridge_dir: Path) -> str | None:
"""
Read the active model id from the statusLine snapshot ``context.json``.
+237 -77
View File
@@ -36,6 +36,7 @@ from omnigent.claude_native_bridge import (
read_hook_events_from_offset,
read_hook_events_since_with_position,
read_message_deltas_from_offset,
read_permission_mode,
read_transcript_items_from_offset,
read_transcript_items_since_with_position,
read_transcript_path,
@@ -82,6 +83,11 @@ _SUBAGENT_IDLE_QUIESCENCE_S = 5.0
# ``agent-<id>.jsonl`` transcript.
_SUBAGENT_META_GLOB = "agent-*.meta.json"
_DEFAULT_POLL_INTERVAL_S = 0.25
# Minimum spacing between permission-mode pane reads. Unlike the model mirror
# (which reads a JSON file), this spawns a ``tmux capture-pane`` subprocess, so
# it runs well below the poll interval; a mode switch is a human action and 2s
# of lag is imperceptible.
_PERMISSION_MODE_POLL_INTERVAL_S = 2.0
# Hard ceiling on one poll iteration of the forward loop. A silently stalled
# await anywhere in the pipeline used to stop mirroring, status and the busy
# signal forever; the deadline cancels the stall (the traceback names it) and
@@ -490,16 +496,15 @@ class _ForwardDedupeState:
:param usage: Last ``message.usage`` snapshot POSTed via
``external_session_usage``, or ``None`` if none yet.
:param context_window: Last context-window POSTed, or ``None``.
:param observed_model: Last tier alias seen in the transcript,
sticky across polls (the incremental window often carries no
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
seen.
:param posted_model: Last tier alias POSTed via
``external_model_change``. Seeded from the first observation
WITHOUT a POST so a passive spawn default never overwrites a
pending silent model handoff; only a later in-TUI switch is
mirrored. Left behind ``observed_model`` on a failed POST so the
next poll retries. ``None`` until the first observation.
:param observed_model: Last VERBATIM model seen (statusLine or
transcript), sticky across polls (the incremental window often
carries no fresh ``message.model``), e.g.
``"claude-opus-4-8[1m]"``. ``None`` until first seen.
:param posted_model: Last verbatim model POSTed via
``external_model_change``. Every observation posts — the first
one is the launch report that seeds the session's
``reported_model``. Left behind ``observed_model`` on a failed
POST so the next poll retries. ``None`` until the first post.
:param posted_cost: Last DISPLAY cost (USD) POSTed as
``cumulative_cost_usd`` — the statusLine total ``S`` verbatim.
``None`` until the first cost post. Used to dedupe so a steady
@@ -510,6 +515,15 @@ class _ForwardDedupeState:
from ``posted_cost`` because it advances mid-turn (with in-flight
sub-agent spend) while ``S`` stays frozen. ``None`` until first
post.
:param observed_title: Last ``custom-title`` seen in the transcript,
sticky across polls, e.g. ``"auth-refactor"``. ``None`` until the
operator runs ``/rename``.
:param posted_title: Last title POSTed via
``external_session_title``. Unlike ``posted_model`` this is NOT
seeded without a POST — a ``custom-title`` record only exists
because the operator renamed the session, so the first
observation is a real change worth mirroring. Left behind
``observed_title`` on a failed POST so the next poll retries.
:param recorded_token_usage: Last token counters recorded on a
``claude_native.usage`` span as ``gen_ai.usage.*``. Deduped
separately from ``usage`` because that snapshot also moves on
@@ -523,6 +537,8 @@ class _ForwardDedupeState:
recorded_token_usage: dict[str, int] | None = None
observed_model: str | None = None
posted_model: str | None = None
observed_title: str | None = None
posted_title: str | None = None
# Last DISPLAY cost (USD) POSTed as ``cumulative_cost_usd`` — the
# statusLine total ``S`` verbatim (matches /cost in the Claude TUI).
# Kept to suppress duplicate posts when S hasn't advanced.
@@ -532,6 +548,13 @@ class _ForwardDedupeState:
# sub-agent spend so the gate can block mid-turn. Separate baseline
# because it can advance while ``posted_cost`` (S) is frozen.
posted_policy_cost: float | None = None
# Last permission mode POSTed as ``external_permission_mode_change`` —
# mirrors the launch mode and any in-pane shift+tab switch, neither of
# which the web UI can observe on its own.
posted_permission_mode: str | None = None
# Monotonic deadline before which the next pane read is skipped, so the
# subprocess spawn runs at _PERMISSION_MODE_POLL_INTERVAL_S, not every poll.
permission_mode_next_read: float = 0.0
# Turn-settle latch driving the scheduled-wake boundary. The Stop edge
# records the ended turn's id as PENDING; it activates (moves to
# ``settled_response_id``) only once a fully-consumed transcript batch
@@ -1001,6 +1024,14 @@ async def forward_claude_transcript_to_session(
bridge_dir=bridge_dir,
dedupe=dedupe,
)
# Same rationale for the permission mode: a shift+tab in
# the pane emits no event, so poll the footer.
await _forward_permission_mode_from_pane(
client=client,
session_id=current_session_id,
bridge_dir=bridge_dir,
dedupe=dedupe,
)
except asyncio.CancelledError:
raise
except TimeoutError:
@@ -3561,19 +3592,27 @@ async def _forward_available_items(
_http_status_for_log(exc),
exc_info=True,
)
# Mirror a TUI-side `/model` switch to the web picker. The transcript
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
# it to the picker's tier alias. This transcript-derived observation
# only fires when a turn produces a fresh ``message.model``, so it lags
# an in-pane switch by one turn — the per-poll statusLine sync
# (:func:`_forward_model_from_status`) is the primary, low-latency
# source; this stays as a fallback for cold-resume before the first
# statusLine render. Both share ``dedupe`` so neither double-posts.
# Report the transcript's model verbatim. This transcript-derived
# observation only fires when a turn produces a fresh
# ``message.model``, so it lags an in-pane switch by one turn — the
# per-poll statusLine sync (:func:`_forward_model_from_status`) is the
# primary, low-latency source; this stays as a fallback for cold-resume
# before the first statusLine render. Both share ``dedupe`` so neither
# double-posts.
await _post_model_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
alias=_model_alias_for(result.latest_model),
model=result.latest_model,
)
# Mirror a TUI-side `/rename` to the web session list. Claude writes the
# operator's title as a `custom-title` metadata record, which renders no
# conversation item, so this is the only path that surfaces it.
await _post_title_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
title=result.latest_custom_title,
)
return updated
@@ -4157,38 +4196,81 @@ def _gen_ai_usage_tokens(usage: Mapping[str, float | str] | None) -> dict[str, i
return tokens
def _model_alias_for(model: str | None) -> str | None:
async def _post_external_permission_mode_change(
client: httpx.AsyncClient,
*,
session_id: str,
mode: str,
) -> None:
"""
Collapse a concrete Claude model id to the picker's tier alias.
Post one ``external_permission_mode_change`` event to the Sessions API.
The web model picker speaks Claude Code's version-agnostic aliases
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
extra concrete-id slot ``"sonnet_5"`` (see
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
transcript records the resolved concrete id (e.g.
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
Mapping to the tier keeps the mirrored value in the picker's
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
default that row is bound to.
Lets the web mode picker reflect a shift+tab switch made inside the Claude
Code terminal, which Omnigent has no other way to observe.
:param model: Concrete model id from the transcript, e.g.
``"claude-opus-4-8"``; ``None`` when none observed yet.
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
``"haiku"`` when the id carries a known tier token, else ``None``
(the caller skips the post rather than surface an id the picker
can't render).
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
:param mode: Permission mode the pane now shows, e.g. ``"auto"``.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
if not model:
return None
lowered = model.lower()
if "sonnet-5" in lowered or "sonnet_5" in lowered:
return "sonnet_5"
for tier in ("fable", "opus", "sonnet", "haiku"):
if tier in lowered:
return tier
return None
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_permission_mode_change", "data": {"permission_mode": mode}},
)
resp.raise_for_status()
async def _forward_permission_mode_from_pane(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
dedupe: _ForwardDedupeState,
) -> None:
"""
Mirror the pane's permission-mode footer to the session label each poll.
A shift+tab pressed inside the TUI produces no event Omnigent can see, so
without this the web picker shows a stale mode until the next UI-driven
switch. Polling the footer is the only signal available: Claude Code emits
nothing on a mode change, and hook payloads only arrive on tool use.
The launch mode is posted too, not just later switches: a session started
in manual mode carries no ``--permission-mode`` arg and no mode label, so
with nothing posted the web picker has no mode to render and hides itself.
Best-effort and idempotent — the server ignores a mode equal to the stored
label, an unchanged mode or unreadable pane is a no-op, and a failed POST
is retried next poll.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param bridge_dir: Native Claude bridge directory.
:param dedupe: Shared per-session dedupe state; mutated in place.
"""
# Throttled: this spawns a tmux subprocess, unlike the file-backed model
# mirror that shares this poll loop.
now = time.monotonic()
if now < dedupe.permission_mode_next_read:
return
dedupe.permission_mode_next_read = now + _PERMISSION_MODE_POLL_INTERVAL_S
mode = await asyncio.to_thread(read_permission_mode, bridge_dir)
if mode is None or mode == dedupe.posted_permission_mode:
return
try:
await _post_external_permission_mode_change(
client,
session_id=session_id,
mode=mode,
)
except httpx.HTTPError:
_logger.debug(
"external_permission_mode_change post failed; session=%s mode=%s",
session_id,
mode,
exc_info=True,
)
return
dedupe.posted_permission_mode = mode
async def _post_external_model_change(
@@ -4200,13 +4282,15 @@ async def _post_external_model_change(
"""
Post one ``external_model_change`` event to the Sessions API.
Lets the web model picker reflect a model switch made inside the
Claude Code terminal (a ``/model`` command or the in-TUI picker).
Reports the model the pane is actually on — the launch's own model
included — so every surface renders the harness's truth.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g.
``"conv_abc123"``.
:param model: Tier alias the session is now on, e.g. ``"opus"``.
:param model: The harness's VERBATIM model, e.g.
``"claude-opus-4-8[1m]"`` — never collapsed to a picker alias
(a family word claims a generation the pane may not be on).
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
@@ -4216,45 +4300,121 @@ async def _post_external_model_change(
resp.raise_for_status()
async def _post_external_session_title(
client: httpx.AsyncClient,
*,
session_id: str,
title: str,
) -> None:
"""
Post one ``external_session_title`` event to the Sessions API.
Mirrors a ``/rename`` typed in the Claude Code pane onto the Omnigent
session title so the web session list stops showing the stale
auto-generated one.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g.
``"conv_abc123"``.
:param title: Operator-chosen title, e.g. ``"auth-refactor"``.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_title", "data": {"title": title}},
)
resp.raise_for_status()
async def _post_title_change_if_new(
client: httpx.AsyncClient,
*,
session_id: str,
dedupe: _ForwardDedupeState,
title: str | None,
) -> None:
"""
Mirror an observed ``/rename`` title to the session, deduped.
Unlike :func:`_post_model_change_if_new`, the FIRST observation is
posted rather than used to seed the baseline silently: a
``custom-title`` record exists only because the operator ran
``/rename``, so there is no passive spawn default to protect.
A steady-state poll reads only records past its byte cursor, so the
dedupe is not for the ordinary case — it covers the cursor rewind /
restart path that re-reads an already-posted record, and it is what
makes the retry below safe to attempt on every poll.
Best-effort: a failed POST leaves ``posted_title`` behind
``observed_title`` so the next poll retries. ``observed_title`` is
sticky for exactly this reason — the retry must survive polls whose
own window carries no rename.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param dedupe: Shared per-session dedupe state; mutated in place.
:param title: Title just observed, or ``None`` when this poll's
window carried no ``custom-title`` record. ``observed_title`` is
sticky, so ``None`` does not clear it — a previously-observed but
unposted title is still retried here.
"""
if title is not None:
dedupe.observed_title = title
if dedupe.observed_title is None or dedupe.observed_title == dedupe.posted_title:
return
try:
await _post_external_session_title(
client,
session_id=session_id,
title=dedupe.observed_title,
)
dedupe.posted_title = dedupe.observed_title
except httpx.HTTPError:
# Leave posted_title behind observed_title so the next poll retries.
_logger.warning(
"Failed to mirror /rename to Omnigent session=%s; the web session "
"list may show a stale title until the next poll",
session_id,
exc_info=True,
)
async def _post_model_change_if_new(
client: httpx.AsyncClient,
*,
session_id: str,
dedupe: _ForwardDedupeState,
alias: str | None,
model: str | None,
) -> None:
"""
Mirror an observed model tier alias to ``model_override``, deduped.
Report the observed model to ``reported_model``, verbatim and deduped.
Shared by the transcript-driven path (:func:`_forward_available_items`)
and the statusLine-driven per-poll path
(:func:`_forward_model_from_status`). The FIRST observation is the
session's spawn default, not a switch, so it seeds the dedupe baseline
WITHOUT posting (posting it could clobber a pending silent model
handoff). Every later change posts ``external_model_change``. Both
callers pass the same ``dedupe`` so whichever observes a switch first
posts it and the other no-ops. Best-effort: a failed POST leaves
``posted_model`` behind ``observed_model`` so the next poll retries.
(:func:`_forward_model_from_status`). EVERY observation posts — the
first one is the launch report that seeds the session's
``reported_model``, so surfaces show the pane's truth within seconds
of spawn; the server dedupes by equality, so a steady model costs one
POST total. Both callers pass the same ``dedupe`` so whichever
observes a change first posts it and the other no-ops. Best-effort: a
failed POST leaves ``posted_model`` behind ``observed_model`` so the
next poll retries.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param dedupe: Shared per-session dedupe state; mutated in place.
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
), or ``None`` when this source carried no recognizable model on
this poll. ``observed_model`` is sticky across polls, so passing
``None`` does NOT clear it — it just means "no fresh observation,"
and a previously-observed-but-unposted model is still reconciled
(retried) here.
:param model: The harness's verbatim model just observed (e.g.
``"claude-opus-4-8[1m]"``), or ``None`` when this source carried
no model on this poll. ``observed_model`` is sticky across
polls, so passing ``None`` does NOT clear it — it just means "no
fresh observation," and a previously-observed-but-unposted model
is still reconciled (retried) here.
"""
if alias is not None:
dedupe.observed_model = alias
if model is not None:
dedupe.observed_model = model
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
return
if dedupe.posted_model is None:
# First observation = the spawn default; seed the baseline without
# posting so it can't clobber a pending silent model handoff.
dedupe.posted_model = dedupe.observed_model
return
try:
await _post_external_model_change(
client,
@@ -4280,7 +4440,7 @@ async def _forward_model_from_status(
dedupe: _ForwardDedupeState,
) -> None:
"""
Mirror the statusLine-reported active model to ``model_override`` each poll.
Report the statusLine's active model to ``reported_model`` each poll.
Claude Code rewrites the statusLine stdin on every TUI render — including
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
@@ -4292,8 +4452,9 @@ async def _forward_model_from_status(
turn later, which is what happened when the model was derived solely
from the next turn's transcript ``message.model``.
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
so a no-op when the model is unchanged.
The value posts VERBATIM — the harness's own spelling, never collapsed
to a picker alias. Best-effort and idempotent: shares ``dedupe`` with
the transcript path, so a no-op when the model is unchanged.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
@@ -4304,12 +4465,11 @@ async def _forward_model_from_status(
if status_state is None:
return
model = status_state.get("model")
alias = _model_alias_for(model if isinstance(model, str) else None)
await _post_model_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
alias=alias,
model=model.strip() if isinstance(model, str) and model.strip() else None,
)
+189 -8
View File
@@ -1781,6 +1781,98 @@ def _set_log_to_stderr(
return value
def _finish_cli_profile(profiler: Any, output_path: Path) -> None: # type: ignore[explicit-any]
"""Stop a requested cProfile run and render a focused bottleneck summary."""
import pstats
profiler.disable()
profiler.dump_stats(output_path)
stats = pstats.Stats(profiler)
stats_state = vars(stats)
raw_stats = cast(
dict[tuple[str, int, str], tuple[int, int, float, float, object]],
stats_state["stats"],
)
total_time = float(stats_state["total_tt"])
total_calls = int(stats_state["total_calls"])
primitive_calls = int(stats_state["prim_calls"])
package_root = Path(__file__).resolve().parent
source_root = package_root.parent
# (filename, line, function, primitive calls, total calls, self, cumulative)
rows = [(*key, cc, nc, tt, ct) for key, (cc, nc, tt, ct, _) in raw_stats.items()]
def _location(filename: str, line: int, function: str) -> str:
path = Path(filename).resolve()
try:
display = str(path.relative_to(source_root))
except ValueError:
display = path.name
return f"{display}:{line}({function})"
def _duration(seconds: float) -> str:
if seconds < 0.01:
return f"{seconds * 1_000:.2f} ms"
if seconds < 1:
return f"{seconds * 1_000:.1f} ms"
return f"{seconds:.3f} s"
def _print_rows(
title: str,
selected: list[tuple[str, int, str, int, int, float, float]],
) -> None:
click.echo(f"\n{title}", err=True)
click.echo(f" {'self':>9} {'cumulative':>10} {'calls':>9} function", err=True)
for filename, line, function, primitive, calls, self_time, cumulative in selected[:10]:
call_count = str(calls) if calls == primitive else f"{calls}/{primitive}"
click.echo(
f" {_duration(self_time):>9} {_duration(cumulative):>10} "
f"{call_count:>9} {_location(filename, line, function)}",
err=True,
)
omnigent_rows = [
row for row in rows if Path(row[0]).resolve().is_relative_to(package_root) and row[6] > 0
]
omnigent_rows.sort(key=lambda row: row[6], reverse=True)
self_time_rows = [row for row in omnigent_rows if row[5] > 0]
self_time_rows.sort(key=lambda row: row[5], reverse=True)
click.echo(
f"\nCLI profile: {_duration(total_time)}, "
f"{total_calls:,} calls ({primitive_calls:,} primitive)",
err=True,
)
_print_rows("Top Omnigent call paths", omnigent_rows)
_print_rows("Top Omnigent functions by self time", self_time_rows)
click.echo(f"\nFull profile data: {output_path}", err=True)
def _start_cli_profile(
ctx: click.Context,
_param: click.Parameter,
value: bool,
) -> bool:
"""Start cProfile early and finish it after the selected command exits."""
if not value:
return value
import cProfile
timestamp = time.strftime("%Y%m%d-%H%M%S")
microseconds = time.time_ns() // 1_000 % 1_000_000
profile_dir = data_dir() / "profiles"
profile_dir.mkdir(parents=True, exist_ok=True)
output_path = profile_dir / (f"omnigent-cli-{timestamp}-{microseconds:06d}-{os.getpid()}.prof")
profiler = cProfile.Profile()
profiler.enable()
ctx.call_on_close(lambda: _finish_cli_profile(profiler, output_path))
# Click closes callbacks last-in-first-out, so measurement stops before
# rendering the summary above.
ctx.call_on_close(profiler.disable)
return value
def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, bool]:
"""Remove global logging flags before run-shorthand rewriting."""
debug_logging = False
@@ -1801,6 +1893,17 @@ def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, boo
@click.group(cls=_OmnigentCLI)
@click.option(
"--profiling",
is_flag=True,
is_eager=True,
expose_value=False,
callback=_start_cli_profile,
help=(
"Profile CLI execution, print a summary, and write a timestamped .prof file. "
"Place before COMMAND."
),
)
@click.option(
"--debug",
"debug_logging",
@@ -2031,7 +2134,17 @@ def main() -> None:
# intentionally tiny (currently only help/version); runner flags live on
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
# users can type the natural no-AGENT launcher form.
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
if (
argv
and argv[0].startswith("-")
and argv[0]
not in {
"--help",
"-h",
"--version",
"--profiling",
}
):
argv = ["run", *argv]
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
@@ -2201,6 +2314,10 @@ def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
# help listing subcommands, not the legacy argparse help.
if argv[0] in {"--help", "-h", "--version"}:
return False
# A root profiling flag may precede an eager help/version flag or stand
# alone. These are valid Click invocations, not removed ad-hoc chat.
if all(token in {"--profiling", "--help", "-h", "--version"} for token in argv):
return False
# Skip leading flags to find the first positional. If all
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
# treat it as removed ad-hoc chat rather than handing it to click
@@ -2863,6 +2980,7 @@ def _spawn_host_daemon_process(
proc = subprocess.Popen(
args,
env=env,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=log_fh,
**_proc.spawn_kwargs(),
@@ -9200,6 +9318,50 @@ def _stop_daemon_sessions(
return stopped
def _signal_daemon_pid(record: _HostDaemonRecord, sig: int) -> bool:
"""
Signal a daemon's recorded PID, tolerating a stale foreign entry.
A daemon registry record can outlive the process it names. The recorded
PID may since have been reused by an unrelated process often owned by
another user or the real daemon may have been started under a different
account (e.g. ``sudo``). In both cases the PID is no longer this user's
daemon and must not be killed.
``os.kill`` raises ``PermissionError`` (EPERM) when the caller lacks
permission to signal the target which, since we only ever signal our
own daemons, means the record is stale and points at someone else's
process. ``_pid_alive`` reports such a PID as alive (``psutil`` maps the
same permission failure to ``AccessDenied``), so without this guard
``_terminate_daemon`` would fall through to ``os.kill`` and crash on the
unsuppressed EPERM ``--force`` included, since it dies before the
SIGKILL path. Log a warning and treat the record as stale instead.
:param record: Daemon record whose PID should be signalled.
:param sig: Signal number to send, e.g. ``signal.SIGTERM``.
:returns: ``True`` if the record is stale and the caller should drop it
and stop (either the PID is not ours, or it already exited); ``False``
if the signal was delivered and termination should proceed as usual.
"""
try:
os.kill(record.pid, sig)
except ProcessLookupError:
# The process exited between the liveness check and the signal —
# nothing left to kill, so the record is stale.
return True
except PermissionError:
# Not our daemon: the record points at another user's process (PID
# reuse, or a daemon started under a different account). Drop the
# stale record and warn rather than crashing the CLI on the EPERM.
click.echo(
f"Skipping stale daemon record for {record.target!r}: pid "
f"{record.pid} is owned by another user and is not this daemon.",
err=True,
)
return True
return False
def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
"""
Terminate one local daemon process.
@@ -9211,8 +9373,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
if not _pid_alive(record.pid):
_delete_daemon_record(record)
return
with contextlib.suppress(ProcessLookupError):
os.kill(record.pid, signal.SIGTERM)
if _signal_daemon_pid(record, signal.SIGTERM):
_delete_daemon_record(record)
return
deadline = time.monotonic() + _HOST_DAEMON_STOP_GRACE_S
while time.monotonic() < deadline:
if not _pid_alive(record.pid):
@@ -9220,8 +9383,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
return
time.sleep(0.1)
if force:
with contextlib.suppress(ProcessLookupError):
os.kill(record.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
if _signal_daemon_pid(record, getattr(signal, "SIGKILL", signal.SIGTERM)):
_delete_daemon_record(record)
return
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if not _pid_alive(record.pid):
@@ -10904,6 +11068,12 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
the workspace rejects it).
:raises click.ClickException: When the Databricks CLI binary is
missing or the login exits non-zero.
The login writes to a ``.databrickscfg`` profile named after the
workspace's first DNS label (e.g. ``acme`` for
``acme.cloud.databricks.com``), keeping distinct workspaces off the
shared ``DEFAULT`` profile. The OAuth grant itself stays host-keyed,
so :func:`_databricks_workspace_token` still resolves it by host.
"""
databricks_bin = shutil.which("databricks")
if databricks_bin is None:
@@ -10912,14 +11082,21 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
)
login_host = _host_with_org(workspace_host, org_id)
click.echo(f"Opening browser to log in to {login_host} ...")
# Pin the grant to a profile named for the workspace's first DNS label so
# distinct workspaces don't clobber each other under the CLI's ``DEFAULT``.
from urllib.parse import urlsplit
split = urlsplit(workspace_host.rstrip("/"))
host = split.hostname or split.netloc or split.path
profile = host.split(".")[0]
click.echo(f"Opening browser to log in to {login_host} (profile {profile}) ...")
result = subprocess.run(
[databricks_bin, "auth", "login", "--host", login_host],
[databricks_bin, "auth", "login", "--host", login_host, "--profile", profile],
check=False,
)
if result.returncode != 0:
raise click.ClickException(
f"`databricks auth login --host {login_host}` failed "
f"`databricks auth login --host {login_host} --profile {profile}` failed "
f"(exit {result.returncode}). If the workspace is unreachable from "
"this machine (VPN / IP access lists), resolve that and retry."
)
@@ -11142,6 +11319,9 @@ def login(server_url: str) -> None:
token=token,
user_id=user_id,
expires_at=_time.time() + expires_in,
# Login-issued refresh grant (newer servers) — lets the
# host/CLI renew past session expiry unattended.
refresh_token=result.get("refresh_token"),
)
click.echo(f"Logged in as {user_id}")
_remember_default_server(server)
@@ -11221,6 +11401,7 @@ def _accounts_login(server: str) -> None:
token=token,
user_id=user_id,
expires_at=_time.time() + expires_in,
refresh_token=body.get("refresh_token"),
)
click.echo(f"Logged in as {user_id}.")
+263 -10
View File
@@ -21,11 +21,13 @@ from __future__ import annotations
import contextlib
import json
import logging
import math
import os
import stat
import tempfile
import time
import urllib.parse
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -35,6 +37,14 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
_TOKEN_FILE_NAME = "auth_tokens.json"
# Treat a stored token with less than this much life left as needing
# renewal. Shared by the "is it still usable" read path and the refresh
# path's already-renewed check, so a caller that decides to refresh is
# never handed back the same near-expiry token it wanted to replace.
# Comfortably longer than a WebSocket handshake, far shorter than the
# server's 1-hour access-token TTL.
REFRESH_MIN_REMAINING_SECONDS = 90.0
def _token_file_path() -> Path:
"""Return the path to the auth token storage file.
@@ -134,6 +144,10 @@ def _store_entry(server_url: str, entry: dict[str, str | float]) -> None:
except (json.JSONDecodeError, OSError):
data = {}
# Corrupt token files (non-dict JSON) read as empty — never crash.
if not isinstance(data, dict):
data = {}
data[_normalize_server_url(server_url)] = entry
_write_tokens_file(path, data)
@@ -144,6 +158,7 @@ def store_token(
token: str,
user_id: str,
expires_at: float,
refresh_token: str | None = None,
) -> None:
"""Persist a session token for a server.
@@ -153,15 +168,19 @@ def store_token(
:param user_id: The authenticated user's email, e.g.
``"alice@example.com"``.
:param expires_at: Unix timestamp when the token expires.
:param refresh_token: Login-issued refresh grant token, when the
server handed one out. Lets :func:`refresh_stored_token` renew
the access token past expiry without a human re-running
``omnigent login``.
"""
_store_entry(
server_url,
{
"token": token,
"user_id": user_id,
"expires_at": expires_at,
},
)
entry: dict[str, str | float] = {
"token": token,
"user_id": user_id,
"expires_at": expires_at,
}
if refresh_token is not None:
entry["refresh_token"] = refresh_token
_store_entry(server_url, entry)
def store_databricks_auth(
@@ -217,11 +236,15 @@ def _load_entry(server_url: str) -> dict[str, str | float] | None:
except (json.JSONDecodeError, OSError):
return None
# A token file holding valid JSON of the wrong shape (``[]``, ``null``,
# a bare string) is corrupt, not fatal — read as "nothing stored".
if not isinstance(data, dict):
return None
entry = data.get(_normalize_server_url(server_url))
return entry if isinstance(entry, dict) else None
def load_token(server_url: str) -> str | None:
def load_token(server_url: str, *, min_remaining_seconds: float = 0.0) -> str | None:
"""Load a stored session token for a server.
Returns ``None`` if no token is stored, the token has expired,
@@ -231,6 +254,11 @@ def load_token(server_url: str) -> str | None:
:param server_url: The server URL, e.g.
``"http://localhost:6767"``.
:param min_remaining_seconds: Require at least this much remaining
lifetime. ``0`` (the default) accepts any not-yet-expired token
the historical behaviour. A caller that can renew passes
:data:`REFRESH_MIN_REMAINING_SECONDS` so a token about to lapse
mid-handshake is refreshed instead of used.
:returns: The session JWT string, or ``None``.
"""
entry = _load_entry(server_url)
@@ -239,13 +267,238 @@ def load_token(server_url: str) -> str | None:
expires_at = entry.get("expires_at", 0)
if isinstance(expires_at, (int, float)) and expires_at < time.time():
_logger.debug("Stored token for %s has expired", _normalize_server_url(server_url))
_warn_expired_once(server_url, expires_at, has_refresh="refresh_token" in entry)
return None
# Near-expiry but still valid: decline quietly (no expiry warning — it
# has not expired) so the caller can choose to renew.
if (
min_remaining_seconds > 0
and isinstance(expires_at, (int, float))
and expires_at - time.time() < min_remaining_seconds
):
return None
token = entry.get("token")
return token if isinstance(token, str) else None
# Servers already warned about an expired stored token, so a poll/retry
# loop doesn't repeat the warning every few seconds.
_warned_expired_servers: set[str] = set()
def _warn_expired_once(server_url: str, expires_at: float, *, has_refresh: bool) -> None:
"""Warn (once per process per server) that a stored token expired.
Expiry used to be a DEBUG line, which left the host dialing
unauthenticated into a misleading 403 with no breadcrumb an
operator's first actionable signal must name the cause and the
remedy.
"""
normalized = _normalize_server_url(server_url)
if normalized in _warned_expired_servers:
return
_warned_expired_servers.add(normalized)
expired_on = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(expires_at))
if has_refresh:
_logger.warning(
"Stored login session for %s expired on %s; refresh will be "
"attempted on the next command if possible",
normalized,
expired_on,
)
else:
_logger.warning(
"Stored login session for %s expired on %s and holds no refresh "
"material. Run `omnigent login %s` to re-authenticate.",
normalized,
expired_on,
normalized,
)
def stored_token_status(server_url: str) -> str:
"""Classify the stored auth state for a server.
Lets callers distinguish "never logged in" from "logged in but the
session lapsed" — the difference between proceeding unauthenticated
(header-mode servers accept that) and surfacing an actionable
re-login message.
:param server_url: The server URL.
:returns: ``"ok"`` (valid token stored), ``"expired"`` (entry exists
but the token lapsed), or ``"absent"`` (no token entry at all;
includes Databricks pointer records, which hold no token).
"""
entry = _load_entry(server_url)
if entry is None or not isinstance(entry.get("token"), str):
return "absent"
expires_at = entry.get("expires_at", 0)
if isinstance(expires_at, (int, float)) and expires_at < time.time():
return "expired"
return "ok"
@contextlib.contextmanager
def _token_file_lock() -> Iterator[None]:
"""Exclusive advisory lock over token-file read-modify-write cycles.
Serializes concurrent refreshes on one machine (host + CLI sharing
``auth_tokens.json``) so only one performs the network exchange and
the other picks up its result. Best-effort on platforms without
``fcntl`` (Windows): the refresh still works, only the local
serialization is lost.
:raises OSError: If the lock file cannot be created or opened the
caller degrades to "cannot refresh" (a state directory we cannot
write is one we could not persist the result to either).
"""
lock_path = _token_file_path().with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
try:
import fcntl
except ImportError:
yield
return
with open(lock_path, "w") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
def refresh_stored_token(server_url: str, *, timeout: float = 10.0) -> str | None:
"""Renew the stored access token from its login-issued refresh grant.
POSTs ``grant_type=refresh_token`` to the server's ``/oauth/token``,
persists the result, and returns the fresh access token. Safe to call
opportunistically: returns ``None`` when there is nothing to refresh
(no entry / no refresh material) or when the server refuses (grant
revoked, past its absolute lifetime, or an older server without the
endpoint).
Runs under the token-file lock and re-checks state after acquiring
it, so of two concurrent callers only one performs the network
refresh and the other returns the already-renewed token.
:param server_url: The server URL, e.g. ``"http://localhost:6767"``.
:param timeout: HTTP timeout in seconds.
:returns: A valid access token, or ``None``.
"""
normalized = _normalize_server_url(server_url)
# Cheap pre-check BEFORE touching the lock file: with no refresh
# material there is nothing to do, and creating a lock file would
# raise on a read-only state directory — masking the caller's other
# credential sources (e.g. the Databricks SDK fallback).
pre = _load_entry(server_url)
if pre is None or not isinstance(pre.get("refresh_token"), str) or not pre["refresh_token"]:
return None
try:
with _token_file_lock():
return _refresh_locked(server_url, normalized, timeout)
except OSError as exc:
# Cannot lock/persist (read-only or full state dir) — a refresh we
# could not store is worse than none, so decline and let the caller
# fall through to its other credential sources.
_logger.debug("Token refresh for %s skipped, state dir unusable: %s", normalized, exc)
return None
def _refresh_locked(server_url: str, normalized: str, timeout: float) -> str | None:
"""Perform the refresh exchange; caller holds the token-file lock."""
entry = _load_entry(server_url)
if entry is None:
return None
# Another process may have refreshed while we waited on the lock. A
# freshly minted token is far from expiry, so this cleanly separates
# "someone already renewed" from "this is the same near-expiry token".
expires_at = entry.get("expires_at", 0)
token = entry.get("token")
if (
isinstance(token, str)
and isinstance(expires_at, (int, float))
and expires_at - time.time() > REFRESH_MIN_REMAINING_SECONDS
):
return token
refresh_token = entry.get("refresh_token")
if not isinstance(refresh_token, str) or not refresh_token:
return None
import httpx
try:
resp = httpx.post(
f"{normalized}/oauth/token",
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
timeout=timeout,
)
except httpx.HTTPError as exc:
_logger.warning("Token refresh against %s failed: %s", normalized, exc)
return None
if resp.status_code != 200:
_logger.warning(
"Token refresh against %s refused (HTTP %d) — run `omnigent login %s` "
"to re-authenticate.",
normalized,
resp.status_code,
normalized,
)
return None
try:
payload = resp.json()
except ValueError:
_logger.warning("Token refresh against %s returned a malformed response", normalized)
return None
if not isinstance(payload, dict):
_logger.warning("Token refresh against %s returned a malformed response", normalized)
return None
access_token = payload.get("access_token")
new_refresh = payload.get("refresh_token")
# Only overwrite the stored pair with genuinely usable material —
# a null/non-string field must never clobber a working credential.
if not isinstance(access_token, str) or not access_token:
_logger.warning("Token refresh against %s returned no access token", normalized)
return None
if not isinstance(new_refresh, str) or not new_refresh:
# A server that renews without returning refresh material keeps the
# one we already hold (login grants deliberately do not rotate).
new_refresh = refresh_token
expires_in = _coerce_expires_in(payload.get("expires_in"))
user_id = entry.get("user_id")
store_token(
server_url,
token=access_token,
user_id=user_id if isinstance(user_id, str) else "",
expires_at=time.time() + expires_in,
refresh_token=new_refresh,
)
# A fresh token means any earlier expiry warning is stale; allow
# a new one if this credential ever lapses again.
_warned_expired_servers.discard(normalized)
_logger.info("Refreshed login session for %s", normalized)
return access_token
def _coerce_expires_in(raw: object) -> float:
"""Return a sane access-token lifetime in seconds from *raw*.
Falls back to one hour for anything missing, non-numeric, or
non-finite ``float("NaN")``/``float("Infinity")`` parse happily but
would yield an expiry that never compares as expired, pinning a dead
token forever.
"""
default = 3600.0
try:
value = float(raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
if not math.isfinite(value) or value <= 0:
return default
return value
def load_databricks_workspace_host(server_url: str) -> str | None:
"""Load the workspace host from a Databricks Apps pointer record.
+12 -6
View File
@@ -853,11 +853,14 @@ async def _prepare_codex_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Codex session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Codex session...")
session_id = await _create_codex_session(
client,
session_bundle,
bridge_id=None,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_codex_session(
client,
session_bundle,
bridge_id=None,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Codex session...")
@@ -910,7 +913,8 @@ async def _prepare_codex_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
@@ -1196,6 +1200,7 @@ async def _prepare_codex_terminal(
socket_path=codex_ws_url,
thread_id=thread_id,
codex_home=str(codex_home),
cwd=str(Path.cwd()),
),
)
if runner_id is not None:
@@ -1417,6 +1422,7 @@ async def _initialize_fresh_terminal_thread(
socket_path=app_server_url,
thread_id=thread_id,
codex_home=str(codex_home_for_bridge_dir(prepared.bridge_dir)),
cwd=str(Path.cwd()),
),
)
return thread_id
+322 -20
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
from omnigent.onboarding.provider_config import ProviderEntry
from omnigent.spec.types import AgentSpec
from omnigent.codex_model_vocabulary import codex_spawn_model
from omnigent.codex_native_bridge import write_policy_hook_config
from omnigent.codex_native_process_registry import (
CodexNativeProcessOwnerLock,
@@ -57,6 +59,7 @@ from omnigent.inner.codex_executor import (
codex_router_session_id,
codex_routing_hook_skip_reason,
materialize_codex_provider_config,
read_codex_model_catalog,
write_codex_hooks_file,
)
from omnigent.inner.databricks_executor import _databricks_gateway_host
@@ -110,6 +113,7 @@ _MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
# (including a version we could not parse) the flag is omitted and the
# interactive trust prompt may appear instead.
_MIN_BYPASS_HOOK_TRUST_CODEX_VERSION = (0, 131, 0)
_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS = 3.0
def _string_object_dict(value: object) -> _JsonObject | None:
@@ -386,6 +390,43 @@ def _sync_codex_developer_instructions(
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
def _codex_model_upgrade_target(catalog: object, model: str) -> str | None:
"""Return Codex's replacement for *model*, when the catalog declares one."""
if not isinstance(catalog, dict):
return None
models = catalog.get("models")
if not isinstance(models, list):
return None
for entry in models:
if not isinstance(entry, dict) or entry.get("slug") != model:
continue
upgrade = entry.get("upgrade")
if not isinstance(upgrade, dict):
return None
target = upgrade.get("model") or upgrade.get("id")
if isinstance(target, str) and target and target != model:
return target
return None
return None
def _acknowledge_codex_model_migration(codex_home: Path, model: str, target: str) -> None:
"""Suppress one model-migration prompt in a private runner-owned config."""
config_path = codex_home / "config.toml"
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
document = tomlkit.parse(existing) if existing else tomlkit.document()
notice = document.get("notice")
if notice is None:
notice = tomlkit.table()
document["notice"] = notice
migrations = notice.get("model_migrations")
if migrations is None:
migrations = tomlkit.table()
notice["model_migrations"] = migrations
migrations[model] = target
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
def _inject_mcp_server_config(
codex_home: Path,
bridge_dir: Path,
@@ -745,13 +786,18 @@ async def _start_codex_model_discovery_process(
listen_url: str,
env: dict[str, str],
cwd: Path,
config_overrides: Sequence[str] = (),
) -> asyncio.subprocess.Process:
"""Start the isolated Codex process used only for model discovery."""
override_args: list[str] = []
for override in config_overrides:
override_args.extend(("-c", override))
return await asyncio.create_subprocess_exec(
codex_path,
"app-server",
"--listen",
listen_url,
*override_args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
@@ -789,6 +835,189 @@ async def _wait_for_discovery_listener(
raise TimeoutError("Timed out waiting for Codex model discovery app-server")
def _probe_codex_home(config_overrides: Sequence[str]) -> Path:
"""
Persistent probe ``CODEX_HOME`` for one provider configuration.
Persistent (unlike the hermetic discovery's temp dir) so Codex's own
``models_cache.json`` ETag handling makes repeat probes cheap; keyed by
the override set so a provider change never replays another provider's
cache. The account's real ``auth.json`` is symlinked in, the same way
a session launch links it: the credential decides which models the
account's catalog lists (login-gated entries, the account default), so
a credential-less probe answers for a catalog no session will see.
:param config_overrides: The probe's ``-c`` overrides.
:returns: The created ``CODEX_HOME`` directory.
"""
key = hashlib.sha256("\n".join(config_overrides).encode("utf-8")).hexdigest()[:12]
home = Path.home() / ".omnigent" / "cache" / "codex-model-probe" / key
home.mkdir(mode=0o700, parents=True, exist_ok=True)
real_auth = _codex_home_config_source_from_env() / "auth.json"
probe_auth = home / "auth.json"
if real_auth.exists():
with contextlib.suppress(OSError):
if probe_auth.is_symlink() or probe_auth.exists():
probe_auth.unlink()
probe_auth.symlink_to(real_auth)
return home
def mark_launch_default(rows: list[_JsonObject], pinned_model: str | None) -> list[_JsonObject]:
"""
Reduce ``model/list`` rows to exactly one ``isDefault`` marker.
The launch-pinned model wins when a row names it (either spelling);
otherwise Codex's own first default stands. Rows are otherwise verbatim.
Codex's own ``isDefault`` is its built-in preference, which says nothing
about the model this session launched on, so a picker that trusted it
would name a model the pane is not running.
:param rows: Raw ``model/list`` rows.
:param pinned_model: The model the session runs, or ``None``.
:returns: The rows with a single default marked.
"""
from omnigent.codex_model_vocabulary import comparable_model_id
codex_default_index: int | None = None
pinned_index: int | None = None
pinned_key = comparable_model_id(pinned_model) if pinned_model else None
marked: list[_JsonObject] = []
for index, row in enumerate(rows):
cleaned = {key: value for key, value in row.items() if key != "isDefault"}
marked.append(cleaned)
if codex_default_index is None and row.get("isDefault") is True:
codex_default_index = index
if pinned_index is None and pinned_key is not None:
for spelling in (row.get("id"), row.get("model")):
if isinstance(spelling, str) and comparable_model_id(spelling) == pinned_key:
pinned_index = index
break
default_index = pinned_index if pinned_index is not None else codex_default_index
if default_index is not None:
marked[default_index]["isDefault"] = True
return marked
async def probe_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
"""
Ask a session-configured Codex app-server for its own model list.
The harness is the source of truth for what a session's ``/model``
picker would offer, so the probe boots ``codex app-server`` with the
SAME materialization a session launch gets for every launch shape.
A Databricks profile contributes its provider overrides (gateway base
URL + minted auth + model pin) and ``DATABRICKS_HOST``; other provider
shapes carry their resolved ``-c`` overrides verbatim; the plain
Codex-login shape probes bare, which yields the ACCOUNT's visible
catalog: the probe home is isolated (never the user's real
``~/.codex``) but links the real ``auth.json`` in the way a session
launch does, so login-gated entries and the account default match what
a live session will offer.
:param codex_path: Optional Codex executable override.
:returns: The probe rows with a single default marked.
:raises ImportError: When the Codex CLI is unavailable.
:raises OSError: When a Databricks profile resolves no workspace host.
:raises RuntimeError: When the probe app-server exits before connecting.
:raises TimeoutError: When the probe app-server does not become ready.
"""
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError("Native Codex model probing requires the 'codex' CLI on PATH.")
config_overrides = list(launch.config_overrides)
pinned_model = launch.model
env = _clean_codex_env()
if launch.profile is not None:
databricks = await asyncio.to_thread(
_databricks_launch_materialization, model=launch.model, profile=launch.profile
)
config_overrides.extend(databricks.config_overrides)
env["DATABRICKS_HOST"] = databricks.host
pinned_model = databricks.model
codex_home = await asyncio.to_thread(_probe_codex_home, config_overrides)
env["CODEX_HOME"] = str(codex_home)
port = _allocate_loopback_port()
listen_url = f"ws://127.0.0.1:{port}"
process = await _start_codex_model_discovery_process(
codex_path=resolved_codex,
listen_url=listen_url,
env=env,
cwd=codex_home,
config_overrides=config_overrides,
)
client: CodexAppServerClient | None = None
try:
await _wait_for_discovery_listener(process, port)
client = CodexAppServerClient(
ws_url=listen_url,
client_name="omnigent-codex-model-probe",
)
await client.connect()
rows = await list_codex_model_options(client)
finally:
if client is not None:
with contextlib.suppress(Exception):
await client.close()
_proc.terminate_tree(process)
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except TimeoutError:
_proc.kill_tree(process)
await process.wait()
return mark_launch_default(rows, pinned_model)
def codex_catalog_fingerprint(launch: NativeCodexLaunch) -> str:
"""The launch-config fingerprint keying codex's shared model catalog.
One formula for every consumer (host boot probe, runner launch, live
write-back), so they read and write the same catalog file. Callers
fingerprint the SHAPE a ``model=None`` resolution so per-session
picks never fragment the catalog.
:param launch: The resolved launch (``resolve_native_codex_launch``).
:returns: A stable fingerprint string.
"""
from omnigent.model_catalog_store import fingerprint_of
return fingerprint_of(
"codex-native", launch.profile, launch.model, tuple(launch.config_overrides)
)
async def codex_launch_catalog(*, codex_path: str | None = None) -> list[_JsonObject] | None:
"""
The shared codex catalog for this host's default shape: store, then probe.
Reads the on-disk catalog for the ``model=None`` launch shape; a miss
pays one session-shaped probe (real auth linked in) and persists the
answer for every later consumer.
:param codex_path: Optional Codex executable override.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
from omnigent import model_catalog_store
try:
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
except Exception: # noqa: BLE001 — a broken provider config means no catalog
_logger.warning("codex catalog: launch shape resolution failed", exc_info=True)
return None
fingerprint = codex_catalog_fingerprint(launch)
async def _probe() -> list[_JsonObject] | None:
try:
return await probe_codex_model_options(codex_path=codex_path)
except Exception: # noqa: BLE001 — probe failure means "no catalog", never a crash
_logger.warning("codex catalog probe failed", exc_info=True)
return None
return await model_catalog_store.ensure_catalog("codex-native", fingerprint, _probe)
def _build_native_codex_app_server_argv(
*,
tagged_argv0: str,
@@ -920,6 +1149,15 @@ class CodexNativeAppServer:
self.router_hooks_registered = router_bridge_dir is not None and policy_hooks_supported
routed_spawns = router_bridge_dir is not None
config_source = _codex_home_config_source_from_env()
model_migration_target: str | None = None
if self.trust_project and self.pinned_model:
catalog = await asyncio.to_thread(
read_codex_model_catalog,
self.codex_path,
config_source,
timeout=_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS,
)
model_migration_target = _codex_model_upgrade_target(catalog, self.pinned_model)
# Off the loop: this copies/symlinks a home AND (on a Smart Routing
# session) shells out to ``codex debug models`` with a 10s timeout. Run
# inline it stalled every other session sharing this event loop for that
@@ -944,6 +1182,12 @@ class CodexNativeAppServer:
)
if self.pinned_model:
_pin_codex_config_model(self.codex_home, self.pinned_model)
if model_migration_target is not None:
_acknowledge_codex_model_migration(
self.codex_home,
self.pinned_model,
model_migration_target,
)
_sync_codex_developer_instructions(
self.codex_home,
self.developer_instructions,
@@ -1722,6 +1966,63 @@ def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
@dataclass(frozen=True)
class _DatabricksLaunchMaterialization:
"""
The Databricks-profile pieces of a Codex launch, shared by the real
app-server build and the model-options probe so the two cannot drift.
:param config_overrides: ``-c`` overrides routing Codex through the
profile's AI Gateway (provider block + auth command + model pin).
:param model: The model the overrides pin, e.g. ``"databricks-gpt-5-4"``
the explicit *model* when given, else the catalog default.
:param host: The profile's workspace origin for ``DATABRICKS_HOST``.
"""
config_overrides: list[str]
model: str
host: str
def _databricks_launch_materialization(
*, model: str | None, profile: str
) -> _DatabricksLaunchMaterialization:
"""
Resolve the Databricks-profile routing pieces of a Codex launch.
Uses the profile's own host so the gateway base URL matches the token
the profile-pinned auth command mints; a ``DATABRICKS_HOST`` override in
the runner env must not point the base URL at another workspace.
:param model: Optional explicit model pin; ``None`` resolves the
catalog default.
:param profile: ``~/.databrickscfg`` profile name, e.g. ``"oss"``.
:returns: The materialized overrides, pinned model, and host.
:raises OSError: When the profile resolves no workspace host.
"""
host = _databricks_gateway_host(profile)
if not host:
raise OSError(
f"Native Codex with Databricks profile {profile!r} (from your "
"provider config) requires a matching ~/.databrickscfg section "
"with a host visible to the runner process."
)
host = host.rstrip("/")
# Resolve against what the workspace actually serves (live UC listing →
# ucode state → bundled catalog), never the bundled catalog alone — its
# legacy ``databricks-`` spellings can 501 on today's gateway.
resolved_model = _resolve_databricks_codex_model(host, profile, model)
return _DatabricksLaunchMaterialization(
config_overrides=_databricks_codex_config_overrides(
model=resolved_model,
base_url=_databricks_codex_base_url(host),
auth_command=_databricks_codex_auth_command(host, profile),
),
model=resolved_model,
host=host,
)
# DATABRICKS-PATCH(codex-live-model-discovery)
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
"""Resolve the codex launch model against what the workspace serves.
@@ -1856,26 +2157,18 @@ def build_codex_native_server(
)
env = _clean_codex_env()
config_overrides: list[str] = []
pinned_model = model
if profile is not None:
# Use the profile's own host so the gateway base URL matches the token
# the profile-pinned auth command mints; a DATABRICKS_HOST override in
# the runner env must not point the base URL at another workspace.
host = _databricks_gateway_host(profile)
if not host:
raise OSError(
f"Native Codex with Databricks profile {profile!r} (from your "
"provider config) requires a matching ~/.databrickscfg section "
"with a host visible to the runner process."
)
host = host.rstrip("/")
config_overrides.extend(
_databricks_codex_config_overrides(
model=_resolve_databricks_codex_model(host, profile, model),
base_url=_databricks_codex_base_url(host),
auth_command=_databricks_codex_auth_command(host, profile),
)
)
env["DATABRICKS_HOST"] = host
databricks = _databricks_launch_materialization(model=model, profile=profile)
config_overrides.extend(databricks.config_overrides)
env["DATABRICKS_HOST"] = databricks.host
# A launch that names no model still routes through the profile's
# resolved model via ``-c model=``, which outranks the config.toml
# copied from the user's shared home. Pin that model in codex's own
# spelling — the vocabulary config.toml and its readers use — so the
# forwarder mirror and cost gate report the model this session runs
# instead of whatever the shared file was last left on.
pinned_model = codex_spawn_model(databricks.model) or databricks.model
if extra_config_overrides:
config_overrides.extend(extra_config_overrides)
if bypass_sandbox:
@@ -1889,6 +2182,15 @@ def build_codex_native_server(
'sandbox_mode="danger-full-access"',
]
)
# Every launch is explicit: the resolved model rides argv (``-c model=``)
# AND the private config copy's ``model =`` line (pinned in ``start``),
# both written from this one value — so a stale line copied from the
# user's shared config can never govern a session, and the two artifacts
# cannot drift.
if pinned_model and not any(
override.split("=", 1)[0] == "model" for override in config_overrides
):
config_overrides.append(f"model={json.dumps(pinned_model)}")
return CodexNativeAppServer(
codex_path=resolved_codex,
socket_path=socket_path,
@@ -1901,7 +2203,7 @@ def build_codex_native_server(
ap_server_url=ap_server_url,
ap_auth_headers=ap_auth_headers,
python_executable=python_executable,
pinned_model=model,
pinned_model=pinned_model,
trust_project=trust_project,
)
+25 -2
View File
@@ -76,6 +76,8 @@ class CodexNativeBridgeState:
``"0196..."``.
:param codex_home: Private per-session ``CODEX_HOME`` path, e.g.
``"/home/user/.omnigent/codex-native/x/codex-home"``.
:param cwd: Native Codex thread working directory, e.g.
``"/home/user/project"``.
:param active_turn_id: Current Codex turn id, if one is running,
e.g. ``"turn_abc123"``.
"""
@@ -85,6 +87,7 @@ class CodexNativeBridgeState:
thread_id: str
codex_home: str
active_turn_id: str | None = None
cwd: str | None = None
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
@@ -339,9 +342,23 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
:returns: The top-level ``model`` from ``config.toml`` (e.g.
``"gpt-5.4"``), or ``None`` when undeterminable.
"""
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
return read_codex_home_config_model(codex_home_for_bridge_dir(bridge_dir))
def read_codex_home_config_model(codex_home: Path) -> str | None:
"""
Read the active model straight from a session's ``CODEX_HOME``.
Same value and fail-safe behaviour as :func:`read_codex_config_model`,
for callers that hold the ``CODEX_HOME`` path (e.g. a live bridge
state) rather than the bridge directory.
:param codex_home: The session's private ``CODEX_HOME`` directory.
:returns: The top-level ``model`` from ``config.toml`` (e.g.
``"gpt-5.4"``), or ``None`` when undeterminable.
"""
try:
data = tomllib.loads(config_path.read_text())
data = tomllib.loads((codex_home / "config.toml").read_text())
except (OSError, tomllib.TOMLDecodeError):
return None
model = data.get("model")
@@ -425,6 +442,7 @@ def write_bridge_state(bridge_dir: Path, state: CodexNativeBridgeState) -> None:
"thread_id": state.thread_id,
"codex_home": state.codex_home,
"active_turn_id": state.active_turn_id,
"cwd": state.cwd,
},
handle,
sort_keys=True,
@@ -686,6 +704,7 @@ def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
thread_id = raw.get("thread_id")
codex_home = raw.get("codex_home")
active_turn_id = raw.get("active_turn_id")
cwd = raw.get("cwd")
if (
not isinstance(session_id, str)
or not session_id
@@ -706,6 +725,7 @@ def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
thread_id=thread_id,
codex_home=codex_home,
active_turn_id=parsed_active_turn_id,
cwd=cwd if isinstance(cwd, str) and cwd else None,
)
@@ -729,6 +749,7 @@ def update_active_turn_id(bridge_dir: Path, active_turn_id: str | None) -> None:
thread_id=state.thread_id,
codex_home=state.codex_home,
active_turn_id=active_turn_id,
cwd=state.cwd,
),
)
@@ -757,6 +778,7 @@ def update_thread_id(bridge_dir: Path, thread_id: str, active_turn_id: str | Non
thread_id=thread_id,
codex_home=state.codex_home,
active_turn_id=active_turn_id,
cwd=state.cwd,
),
)
@@ -802,6 +824,7 @@ def clear_active_turn_id_if_matches(bridge_dir: Path, completed_turn_id: str | N
thread_id=state.thread_id,
codex_home=state.codex_home,
active_turn_id=None,
cwd=state.cwd,
),
)
return True
+84 -14
View File
@@ -192,14 +192,10 @@ _CODEX_ELICITATION_REQUEST_METHODS = frozenset(
}
)
# Turn-error surfacing. A failed Codex turn arrives as ``turn/completed``
# (or ``turn/failed``) with ``turn.status == "failed"`` and a ``turn.error``
# object ``{message, codexErrorInfo?, additionalDetails?}``; keying status off
# the method alone mapped such turns to ``idle`` — a "silent success". The
# forwarder inspects ``turn.status``/``turn.error``, forces ``failed``, and
# surfaces the reason. As a fallback it also catches an ``error`` ThreadItem in
# ``turn.items``: both shapes exist in the app-server type system and the wire
# shape varies by version, so detecting either keeps the fix robust.
# Turn-error surfacing. Codex reports failures through a standalone ``error``
# notification and on terminal turn boundaries via ``turn.error`` / failed
# status. The forwarder handles both, plus the older ``error`` ThreadItem
# fallback, so every non-retrying failure reaches the session UI.
#
# ``codexErrorInfo`` is the app-server's structured classification (e.g.
# ``unauthorized``, ``usage_limit_exceeded``); auth-class values get a re-auth
@@ -359,6 +355,9 @@ class _CodexForwarderState:
:param synced_item_keys: Stable item keys already posted to Omnigent this
connection, e.g. ``{"thread_c:turn_c:item-1"}``. In-memory only;
guards replay-vs-live overlap within one forwarder lifetime.
:param surfaced_terminal_error_turns: Turn ids whose standalone terminal
``error`` notification was already surfaced. Used to suppress a later
terminal boundary for the same turn.
:param posted_user_turns: Turn ids whose ``userMessage`` has been
posted to Omnigent this connection, e.g. ``{"turn_123"}``. Used to
enforce user-before-assistant ordering: before posting a turn's
@@ -406,6 +405,7 @@ class _CodexForwarderState:
pending_child_threads: dict[str, str | None] = field(default_factory=dict)
subscribed_child_threads: set[str] = field(default_factory=set)
synced_item_keys: set[str] = field(default_factory=set)
surfaced_terminal_error_turns: set[str] = field(default_factory=set)
posted_user_turns: set[str] = field(default_factory=set)
posted_tool_calls: set[str] = field(default_factory=set)
partial_text_by_turn: dict[str, list[_PartialTextBuffer]] = field(default_factory=dict)
@@ -1006,6 +1006,15 @@ def _terminal_error_from_turn(params: _JsonObject) -> _CodexTerminalError | None
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
def _terminal_error_from_notification(params: _JsonObject) -> _CodexTerminalError | None:
"""Return the failure carried by Codex's standalone ``error`` notification."""
payload = params.get("error")
if not isinstance(payload, dict):
return None
message = _error_payload_message(payload)
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
@dataclass(frozen=True)
class _CodexTurnStatusEdge:
"""
@@ -2985,6 +2994,41 @@ async def _maybe_handle_turn_event(
:param forwarder_state: Optional forwarder state.
:returns: ``True`` when this event was handled.
"""
if method == "error":
if params.get("willRetry") is True:
_logger.info(
"Codex forwarder observed retryable turn error: turn_id=%s",
_turn_id_from_payload(params),
)
return True
if delta_coalescer is not None:
await delta_coalescer.flush()
error = _terminal_error_from_notification(params)
if error is None:
_logger.warning("Codex forwarder ignored malformed error notification")
return True
turn_id = _turn_id_from_payload(params)
if forwarder_state is not None and turn_id is not None:
if turn_id in forwarder_state.surfaced_terminal_error_turns:
_logger.info(
"Codex forwarder ignored duplicate terminal error: turn_id=%s",
turn_id,
)
return True
forwarder_state.surfaced_terminal_error_turns.add(turn_id)
clear_active_turn_id_if_matches(bridge_dir, turn_id)
await _post_turn_status_edge(
client,
session_id,
_CodexTurnStatusEdge(
status="failed",
turn_id=turn_id,
source="error",
error=error,
),
)
await usage_coalescer.flush()
return True
if method == "turn/started":
if delta_coalescer is not None:
await delta_coalescer.flush()
@@ -3230,7 +3274,14 @@ async def _handle_terminal_turn_boundary(
params=params,
forwarder_state=forwarder_state,
)
handled = await _handle_terminal_turn_event(client, session_id, bridge_dir, method, params)
handled = await _handle_terminal_turn_event(
client,
session_id,
bridge_dir,
method,
params,
forwarder_state=forwarder_state,
)
if handled:
await elicitation_tracker.resolve_by_terminal_turn_event(
client,
@@ -4124,21 +4175,38 @@ async def _handle_terminal_turn_event(
bridge_dir: Path,
method: str,
params: _JsonObject,
*,
forwarder_state: _CodexForwarderState | None = None,
) -> bool:
"""
Forward a terminal-observed Codex turn completion/failure event.
Handle a terminal-observed Codex turn completion/failure event.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param method: Codex method, e.g. ``"turn/completed"``.
:param params: Codex turn event params.
:returns: ``True`` when the terminal event belonged to the active
turn and was forwarded, ``False`` when it was stale.
:param forwarder_state: Optional connection state used to suppress a
terminal boundary whose standalone error was already surfaced.
:returns: ``True`` when the terminal event belonged to the active turn
and its lifecycle was handled, ``False`` when it was stale.
"""
terminal_turn_id = _terminal_turn_id_from_params(params)
if (
forwarder_state is not None
and terminal_turn_id is not None
and terminal_turn_id in forwarder_state.surfaced_terminal_error_turns
):
clear_active_turn_id_if_matches(bridge_dir, terminal_turn_id)
_logger.info(
"Codex forwarder suppressed terminal boundary after standalone error: "
"method=%s turn_id=%s",
method,
terminal_turn_id,
)
return True
edge = _terminal_turn_status_edge(bridge_dir, method, params)
if edge is None:
terminal_turn_id = _terminal_turn_id_from_params(params)
_logger.info(
"Codex forwarder ignored stale terminal turn event: method=%s turn_id=%s",
method,
@@ -6278,7 +6346,9 @@ def _session_usage_data_from_params(params: _JsonObject) -> dict[str, int] | Non
if not isinstance(total, dict):
return None
cumulative_input_tokens = total.get("inputTokens")
context_window = total.get("contextWindow")
context_window = token_usage.get("modelContextWindow")
if not isinstance(context_window, int) or context_window <= 0:
context_window = total.get("contextWindow")
output_tokens = total.get("outputTokens")
cached_input_tokens = total.get("cachedInputTokens")
data: dict[str, int] = {}
+9 -5
View File
@@ -511,10 +511,13 @@ async def _prepare_cursor_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Cursor session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Cursor session...")
session_id = await _create_cursor_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_cursor_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
# Persist the model pin before the runner binds and launches the
# TUI (it reads model_override from the snapshot to build --model).
@@ -575,7 +578,8 @@ async def _prepare_cursor_terminal_via_daemon(
_update_startup_progress(startup_progress, "Updating Cursor session...")
await _patch_cursor_session(client, session_id, patch)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+3
View File
@@ -1455,6 +1455,9 @@ class SqlScheduledTask(OmnigentBase):
# mirror the matching conversations.* override columns.
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Per-firing cost budget in USD. When set, the fire path attaches a
# cost_budget policy to each spawned session. NULL = no per-firing cap.
max_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
# Git base ref a firing branches from when it creates a worktree at fire
# time (mirrors session-create's git.base_branch input). None when unset.
@@ -0,0 +1,36 @@
"""add max_cost_usd column to scheduled_tasks
Revision ID: za1b2c3d4e5f
Revises: za2b3c4d5e6f
Create Date: 2026-08-13 00:00:00.000000
Adds an optional ``max_cost_usd`` (FLOAT, nullable) column to
``scheduled_tasks``. When set, the fire path attaches a ``cost_budget``
policy to each spawned session capping cumulative spend at this limit.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "za1b2c3d4e5f"
down_revision: str | None = "za2b3c4d5e6f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add max_cost_usd to scheduled_tasks."""
op.add_column(
"scheduled_tasks",
sa.Column("max_cost_usd", sa.Float(), nullable=True),
)
def downgrade() -> None:
"""Remove max_cost_usd from scheduled_tasks."""
with op.batch_alter_table("scheduled_tasks") as batch_op:
batch_op.drop_column("max_cost_usd")
+69
View File
@@ -10,6 +10,7 @@ import time
import uuid
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from contextvars import ContextVar
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
@@ -553,6 +554,54 @@ def clear_engine_cache() -> None:
# ── Managed session ────────────────────────────────────
# Ambient per-engine sessions for a read-only "share one checkout" scope. When
# active (see :func:`shared_read_scope`), ``managed_session()`` reuses the
# scope's session for its engine instead of opening a fresh pool checkout,
# collapsing several back-to-back reads (e.g. the access-control check's
# permission + conversation lookups) into a single connection round-trip.
# Keyed by ``id(engine)`` so distinct engines (split-DB) still get independent
# checkouts. Unset outside a scope, so it is a strict no-op for every ordinary
# caller.
_shared_read_sessions: ContextVar[dict[int, Session] | None] = ContextVar(
"omnigent_shared_read_sessions", default=None
)
@contextmanager
def shared_read_scope() -> Iterator[None]:
"""Collapse back-to-back reads into one pool checkout per engine.
Within this scope, ``managed_session()`` reuses a single session per
engine rather than checking out a fresh pooled connection (plus a
``pool_pre_ping`` round-trip) on every store call. Intended for a short,
strictly READ-ONLY burst an access-control check, a snapshot assembly
where the per-call checkout dominates the actual query time.
Nesting reuses the outer scope. Write makers (``immediate=True``) never
participate, so they keep their own ``BEGIN IMMEDIATE`` isolation even
when nested here. Never hold this open across network I/O: it pins a
pooled connection for the scope's whole duration.
"""
if _shared_read_sessions.get() is not None:
# Already inside a scope — the outer one owns the sessions.
yield
return
sessions: dict[int, Session] = {}
token = _shared_read_sessions.set(sessions)
try:
yield
for session in sessions.values():
session.commit()
except BaseException:
for session in sessions.values():
session.rollback()
raise
finally:
for session in sessions.values():
session.close()
_shared_read_sessions.reset(token)
def make_managed_session_maker(
engine: Engine,
*,
@@ -592,7 +641,27 @@ def make_managed_session_maker(
Commits on clean exit, rolls back on exception. For SQLite
backends, enables foreign key enforcement and sets a
busy timeout before yielding.
Inside a :func:`shared_read_scope` (and only for read makers), the
scope's per-engine session is reused instead of a fresh checkout;
the scope not this block owns its commit/close.
"""
if not immediate:
shared = _shared_read_sessions.get()
if shared is not None:
key = id(engine)
session = shared.get(key)
if session is None:
session = factory()
# Register before the PRAGMAs: those executes force the pool
# checkout, so if one raises the scope must already track the
# session to close it (otherwise the connection would leak).
shared[key] = session
if is_sqlite:
session.execute(text("PRAGMA foreign_keys = ON"))
session.execute(text("PRAGMA busy_timeout = 20000")) # 20s
yield session
return
with factory() as session:
try:
if is_sqlite:
+10 -3
View File
@@ -103,9 +103,15 @@ class Conversation:
(alongside the runner-binding primitive of the Alpha
runner-state design). Both paths validate the value against
the supported set; invalid values fail with ``invalid_input``.
:param model_override: Per-session LLM model override,
e.g. ``"claude-opus-4-7"``. ``None`` means use the agent
default from the spec's ``llm.model``. Mutable via
:param reported_model: The model the harness last REPORTED the
session is actually on, verbatim in the harness's own
spelling, e.g. ``"claude-opus-4-8[1m]"``. Written only by
harness reports (``external_model_change``); never by user
picks. The only model value UI surfaces display. ``None``
means no report has arrived yet.
:param model_override: Per-session LLM model override the user's
REQUEST, e.g. ``"claude-opus-4-7"``. ``None`` means use the
agent default from the spec's ``llm.model``. Mutable via
``PATCH /v1/sessions/{id}`` and the REPL's ``/model``
command. Mirrors the persistence shape of
``reasoning_effort`` so the web UI and the TUI stay
@@ -221,6 +227,7 @@ class Conversation:
session_usage: dict[str, Any] = field(default_factory=dict)
reasoning_effort: str | None = None
model_override: str | None = None
reported_model: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
harness_override: str | None = None
+6
View File
@@ -45,6 +45,11 @@ class ScheduledTask:
``"claude-opus-4-7"``. ``None`` means use the agent default.
:param reasoning_effort: Per-task reasoning-effort hint, e.g. ``"high"``.
``None`` means use the agent default.
:param max_cost_usd: Optional per-firing cost budget in USD. When set, the
fire path attaches a ``cost_budget`` policy to each spawned session that
blocks all models once cumulative spend reaches this limit. ``None``
means no per-firing cost cap (the session runs unconstrained unless the
agent spec or server-wide defaults impose one).
:param workspace: Absolute existing path where a fired session's connected
host runner should start. ``None`` only for legacy or invalid rows.
:param base_branch: Reserved legacy column; scheduled tasks currently do
@@ -74,6 +79,7 @@ class ScheduledTask:
workspace_id: int = 0
model_override: str | None = None
reasoning_effort: str | None = None
max_cost_usd: float | None = None
workspace: str | None = None
base_branch: str | None = None
execution_target: str = "connected_host"
+9 -5
View File
@@ -326,10 +326,13 @@ async def _prepare_goose_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Goose session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Goose session...")
session_id = await _create_goose_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_goose_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Goose session...")
@@ -372,7 +375,8 @@ async def _prepare_goose_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -324,10 +324,13 @@ async def _prepare_hermes_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Hermes session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Hermes session...")
session_id = await _create_hermes_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_hermes_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Hermes session...")
@@ -370,7 +373,8 @@ async def _prepare_hermes_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+303 -132
View File
@@ -29,7 +29,7 @@ from websockets.exceptions import ConnectionClosed, InvalidStatus, InvalidURI
from omnigent._platform import IS_POSIX, WINDOWS_ENV_PASSTHROUGH
from omnigent.env_credentials import env_names_with_omnigent_prefix
from omnigent.gateway_inference import gateway_inference_map
from omnigent.harness_aliases import canonicalize_harness
from omnigent.harness_aliases import canonicalize_harness, is_claude_sdk_harness_name
from omnigent.harness_availability import HARNESS_BINARY_MISSING, HarnessAvailability
from omnigent.host import HOST_FATAL_EXIT_CODE
from omnigent.host.frames import (
@@ -128,6 +128,7 @@ from omnigent.runner.transports.ws_tunnel.limits import (
TUNNEL_KEEPALIVE_PING_INTERVAL_S,
TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
)
from omnigent.suspend_watch import watch_for_resume
from omnigent.tls import client_ssl_context
from omnigent.version import VERSION
@@ -326,15 +327,24 @@ def _connection_refused(exc: BaseException) -> bool:
_RECONNECT_BASE_S = 0.5
_RECONNECT_CAP_S = 10.0
_RECONNECT_CAP_S = 3.0
_RECONNECT_JITTER = 0.5
# Keep first startup tolerant of a cold server, but do not spend the library's
# full default timeout on each reconnect after an established tunnel drops.
_INITIAL_CONNECT_OPEN_TIMEOUT_S = 10.0
_RECONNECT_OPEN_TIMEOUT_S = 3.0
# Fresh hosts get a short auth-retry window for Databricks OAuth refreshes.
# Established hosts retry auth failures indefinitely to preserve sessions.
_MAX_CONSECUTIVE_AUTH_ERRORS = 3
# Consecutive connection-refused failures against a loopback server before the
# host exits (~5 minutes at the backoff cap). Refused on loopback means no
# process listens on the port — the local server is gone, not unreachable.
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 30
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 100
# Consecutive post-connect 401/403 rejections (~5 min at the backoff cap)
# before the retry loop escalates from "check your VPN" to a re-auth prompt.
# Operator-facing only — the host keeps retrying and never exits.
_AUTH_REJECT_ESCALATE_ATTEMPTS = 30
# Consecutive accepted-then-silent connections (upgrade completed, then the
# socket died without one inbound frame) before the reconnect loop treats the
@@ -773,6 +783,19 @@ def _paginate_list_dir(
)
@dataclass(frozen=True)
class ModelOptionsResult:
"""One resolved model listing: picker rows + the settable-but-unlisted ids.
:param models: Verbatim catalog rows (id/model/displayName/isDefault).
:param routable_models: Ids a launch can pin that the picker does not
list (older generations the endpoint still serves).
"""
models: list[dict[str, object]]
routable_models: list[str]
@dataclass
class _RunnerHandle:
"""A spawned runner subprocess and where its output lands.
@@ -898,6 +921,15 @@ class HostProcess:
# Strong refs to in-flight frame tasks (create_task results are
# otherwise GC-able); each discards itself on completion.
self._frame_tasks: set[asyncio.Task[None]] = set()
# Background watcher that force-drops a stale tunnel on wake from system
# suspend (laptop sleep) so the reconnect loop reattaches at once
# instead of waiting out the ~90s keepalive timeout. See run() /
# _on_resume_from_suspend.
self._suspend_task: asyncio.Task[None] | None = None
# Set by _on_resume_from_suspend when it aborts a live tunnel after a
# detected resume; read+cleared in run()'s reconnect handler to force a
# prompt reconnect (skip the backoff).
self._woke_from_suspend = False
def _tracked_runner_pids(self) -> set[int]:
"""PIDs of runners this host spawned and still tracks directly.
@@ -1244,18 +1276,37 @@ class HostProcess:
self._auth_retry_streak < _MAX_CONSECUTIVE_AUTH_ERRORS
)
if should_retry:
_logger.warning("%s Retrying — check your VPN/network.", cause)
if should_retry and self._auth_retry_streak == 1:
# The warning above lands only in the CLI log file; print once
# per outage so a foreground `omnigent host` isn't silent.
print(
f"{cause} Retrying — this usually means the VPN or "
"network dropped. It will reconnect automatically once "
"connectivity returns.",
file=sys.stderr,
flush=True,
)
if should_retry:
# A sustained streak (vs. a brief VPN blip) means the credential
# is very likely permanently rejected: escalate the operator
# signal and name re-auth, but keep retrying so a real outage
# self-heals.
if (
self._auth_retry_streak >= _AUTH_REJECT_ESCALATE_ATTEMPTS
and self._auth_retry_streak % _AUTH_REJECT_ESCALATE_ATTEMPTS == 0
):
escalated = (
f"{cause} The server has rejected it "
f"{self._auth_retry_streak} times in a row — this is no "
"longer a transient network blip. If it persists, the "
"stored credential is likely no longer valid: run "
f"`omnigent login {self._server_url}` and restart the "
"host. Still retrying."
)
_logger.warning("%s", escalated)
print(f"{escalated}", file=sys.stderr, flush=True)
else:
_logger.warning("%s Retrying — check your VPN/network.", cause)
if self._auth_retry_streak == 1:
# The warning above lands only in the CLI log file;
# print once per outage so a foreground `omnigent host`
# isn't silent.
print(
f"{cause} Retrying — this usually means the VPN or "
"network dropped. It will reconnect automatically "
"once connectivity returns.",
file=sys.stderr,
flush=True,
)
return None
if status == 401:
return HostConnectError(
@@ -1266,6 +1317,20 @@ class HostProcess:
+ self._login_fix_hint()
)
if status == 403:
# An expired stored login is the common way to land here: the
# token loader yields nothing, the dial goes out
# unauthenticated, and the server's refusal looks like an
# authorization or version-skew problem. Name the real cause.
from omnigent.cli_auth import stored_token_status
if stored_token_status(self._server_url) == "expired":
return HostConnectError(
"Connection refused (HTTP 403): your stored login "
f"session for {self._server_url} has EXPIRED, so the "
"tunnel was dialed without credentials. Run `omnigent "
f"login {self._server_url}` to re-authenticate, then "
"restart the host."
)
return HostConnectError(
"Connection refused (HTTP 403): the server repeatedly rejected the host "
"tunnel. Either your "
@@ -2216,6 +2281,70 @@ class HostProcess:
payload=payload,
)
async def _prewarm_model_options(self) -> None:
"""
Fill the on-disk model catalogs for the probing harnesses at boot.
Runs both harness probes CONCURRENTLY, as detached background work
nothing (the tunnel, registration, readiness reporting, launches)
ever waits on this. A picker request racing the boot probe joins the
same single-flight probe through the shared store instead of
starting a second one.
:returns: None. Probe failures are absorbed by the probe wrappers.
"""
await asyncio.gather(
self._probed_codex_model_options(),
self._probed_claude_model_options(),
return_exceptions=True,
)
async def _probed_codex_model_options(self) -> ModelOptionsResult | None:
"""
Store-backed harness-truth Codex listing, or ``None`` on failure.
Every launch shape is answered from the shared on-disk catalog
(probed from the configured Codex binary on a miss). There is no
curated fallback: no catalog means an honest empty answer.
:returns: The catalog listing, or ``None`` when unavailable.
"""
from omnigent.codex_native_app_server import codex_launch_catalog
try:
rows = await codex_launch_catalog()
except Exception: # noqa: BLE001 — no catalog, never a crash
_logger.warning("Codex model catalog unavailable", exc_info=True)
return None
if rows is None:
return None
routable = [row["id"] for row in rows if isinstance(row.get("id"), str) and row["id"]]
return ModelOptionsResult(models=rows, routable_models=routable)
async def _probed_claude_model_options(self) -> ModelOptionsResult | None:
"""
Store-backed harness-truth Claude listing, or ``None`` on failure.
The shared catalog is keyed by the resolved launch config's
fingerprint the same file the runner reads at launch and serves in
the session gear, so the pre-launch picker and the session cannot
drift.
:returns: The catalog listing, or ``None`` when unavailable.
"""
from omnigent.claude_native import claude_launch_catalog, resolve_native_claude_config
try:
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
rows = await claude_launch_catalog(config)
except Exception: # noqa: BLE001 — no catalog, never a crash
_logger.warning("Claude model catalog unavailable", exc_info=True)
return None
if rows is None:
return None
routable = list(config.routable_models) if config is not None else []
return ModelOptionsResult(models=rows, routable_models=routable)
async def _handle_model_options(
self,
frame: HostModelOptionsFrame,
@@ -2230,107 +2359,23 @@ class HostProcess:
"""
harness = canonicalize_harness(frame.harness) or frame.harness
if harness == "codex-native":
try:
from omnigent.codex_native_app_server import (
discover_codex_model_options,
resolve_native_codex_launch,
)
from omnigent.model_catalog import (
is_direct_openai_provider,
list_models_for_worker,
resolve_catalog_model,
resolve_model_provider,
)
from omnigent.spec.types import AgentSpec, ExecutorSpec
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
spec = AgentSpec(
spec_version=1,
name="codex-native-prelaunch",
executor=ExecutorSpec(
type="omnigent",
config={
"harness": "codex-native",
**({"profile": launch.profile} if launch.profile else {}),
},
),
)
listing = await asyncio.to_thread(list_models_for_worker, spec, "codex-native")
default_model = launch.model
if default_model is None and launch.profile is not None:
default_model = (
await asyncio.to_thread(
resolve_catalog_model,
"databricks",
family="openai",
)
).model_id
default_id = (
default_model if default_model in {m.id for m in listing.models} else None
)
provider = (
resolve_model_provider(spec, "codex-native")
if listing.source == "openai-compatible"
else None
)
models: list[dict[str, object]]
if provider is not None and is_direct_openai_provider(provider):
available_ids = {model.id for model in listing.models}
models = []
seen: set[str] = set()
selected_default = False
try:
codex_options = await discover_codex_model_options()
except Exception:
_logger.exception("Failed to discover Codex-compatible pre-launch models")
codex_options = []
for option in codex_options:
raw_id = option.get("model") or option.get("id")
if (
not isinstance(raw_id, str)
or raw_id not in available_ids
or raw_id in seen
):
continue
seen.add(raw_id)
display_name = option.get("displayName")
is_default = raw_id == default_id or (
default_model is None
and not selected_default
and option.get("isDefault") is True
)
selected_default = selected_default or is_default
models.append(
{
"id": raw_id,
"displayName": (
display_name
if isinstance(display_name, str) and display_name
else raw_id
),
**({"isDefault": True} if is_default else {}),
}
)
else:
models = [
{
"id": model.id,
"displayName": model.id,
**({"isDefault": True} if model.id == default_id else {}),
}
for model in listing.models
]
except Exception:
_logger.exception("Failed to resolve pre-launch Codex model options")
# Harness-truth lane: every launch shape is answered from the
# shared catalog, probed from the configured Codex binary itself.
# No curated fallback and no serving-endpoints listing — a probe
# that cannot run yields an honest empty answer with the reason.
probed = await self._probed_codex_model_options()
if probed is not None:
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Codex model options",
status="ok",
models=probed.models,
routable_models=probed.routable_models,
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=models,
models=[],
error="the codex model probe failed — see the host log",
)
if harness == "pi-native":
@@ -2351,34 +2396,67 @@ class HostProcess:
models=pi_models,
)
if is_claude_sdk_harness_name(harness):
# SDK-mode Claude is a pass-through client with no model catalog
# of its own, so the endpoint listing IS the harness truth — the
# ids are already in the exact spelling the SDK sends.
try:
from omnigent.model_catalog import list_models_for_worker
from omnigent.spec.types import AgentSpec, ExecutorSpec
sdk_spec = AgentSpec(
spec_version=1,
name="claude-sdk-prelaunch",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "claude-sdk"},
),
)
listing = await asyncio.to_thread(list_models_for_worker, sdk_spec, "claude-sdk")
except Exception:
_logger.exception("Failed to resolve pre-launch Claude SDK model options")
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Claude SDK model options",
)
if not listing.models:
# Subscription / CLI-login providers list nothing endpoint-side.
# The SDK drives the claude CLI, so the CLI's own probed rows
# (its aliases resolve inside the harness) are the truth here.
probed = await self._probed_claude_model_options()
if probed is not None:
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=probed.models,
routable_models=probed.routable_models,
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=[{"id": model.id, "displayName": model.id} for model in listing.models],
routable_models=[model.id for model in listing.models],
)
if harness != "claude-native":
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error=f"model options are unsupported for harness {frame.harness!r}",
)
try:
from omnigent.claude_native import (
claude_native_model_options,
resolve_native_claude_config,
)
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
models = await asyncio.to_thread(claude_native_model_options, config)
except Exception:
_logger.exception("Failed to resolve pre-launch Claude model options")
probed = await self._probed_claude_model_options()
if probed is not None:
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Claude model options",
status="ok",
models=probed.models,
routable_models=probed.routable_models,
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=models,
# The picker names the newest model of each family; the endpoint
# serves older generations too, and a launch takes an exact id.
routable_models=list(config.routable_models) if config is not None else [],
models=[],
error="the claude model probe failed — see the host log",
)
@staticmethod
@@ -2634,6 +2712,12 @@ class HostProcess:
self._reaper_task = asyncio.create_task(
self._orphan_reaper_loop(), name="host-orphan-reaper"
)
# Detect wake from system suspend (laptop sleep) and force-drop the
# then-dead tunnel so the reconnect loop reattaches within seconds
# instead of waiting out the ~90s keepalive ping timeout.
self._suspend_task = asyncio.create_task(
watch_for_resume(self._on_resume_from_suspend), name="host-suspend-watch"
)
# Warm the runner zygote now: start() blocks on its one-time import
# of the runner graph (~1-2s), which otherwise lands inside the first
# session launch of the daemon's life. Best-effort — a failure
@@ -2749,16 +2833,29 @@ class HostProcess:
# A silent-connect streak overrides the recycle fast path:
# prompt reconnects are for endpoints that answer.
silent_churn = self._silent_connect_streak >= _SILENT_CONNECT_ESCALATE_ATTEMPTS
recycle = (
explicit_recycle
or (ingress_recycle and not _url_is_loopback(self._server_url))
) and not silent_churn
# A resume from system suspend (laptop wake) always reconnects
# promptly: _on_resume_from_suspend already aborted the dead
# tunnel, but the abrupt "no close frame" that abort produces
# counts as a benign recycle only on a REMOTE server — a local
# server would otherwise ride the escalating backoff. OR woke in
# outside the silent-churn gate so wake never takes the slow path.
woke = self._woke_from_suspend
self._woke_from_suspend = False
recycle = woke or (
(
explicit_recycle
or (ingress_recycle and not _url_is_loopback(self._server_url))
)
and not silent_churn
)
wait_s = _RECONNECT_BASE_S if recycle else backoff
_logger.warning(
"Host tunnel disconnected: %s. Reconnecting in %.1fs%s",
exc,
wait_s,
" (recycle — prompt reconnect)" if recycle else "",
" (resumed from suspend — prompt reconnect)"
if woke
else (" (recycle — prompt reconnect)" if recycle else ""),
)
await asyncio.sleep(wait_s)
import random
@@ -2780,6 +2877,11 @@ class HostProcess:
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._reaper_task
self._reaper_task = None
if self._suspend_task is not None:
self._suspend_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._suspend_task
self._suspend_task = None
if self._zygote_prestart_task is not None:
self._zygote_prestart_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
@@ -2802,6 +2904,43 @@ class HostProcess:
self._zygote.stop()
self._zygote = None
def _on_resume_from_suspend(self, gap_s: float) -> None:
"""Force-drop the tunnel after a detected wake from system suspend.
On laptop sleep the WebSocket becomes a half-open socket the server
already dropped; without this the reconnect loop waits out the ~90s
keepalive ping timeout (:data:`TUNNEL_KEEPALIVE_PING_TIMEOUT_S`),
leaving the host and every session it owns offline that whole
time. Aborting the transport makes :meth:`_serve_frames`' ``recv``
raise ``ConnectionClosed`` now, and the flag makes :meth:`run` skip the
backoff so the reconnect is prompt.
No-op when no connection is live (e.g. the wake landed during a
reconnect backoff): there is nothing to abort, and the pending backoff
sleep's deadline is already past so it reconnects immediately anyway.
The flag is only set when a live tunnel was actually aborted, so a
wake-during-backoff never triggers a spurious prompt reconnect.
Runs synchronously on the event loop (invoked by the suspend watcher),
so reading ``self._ws`` and aborting is atomic w.r.t. ``_serve_frames``
no lock needed.
:param gap_s: Approximate seconds the machine was asleep (for logging).
:returns: None.
"""
ws = self._ws
if ws is None:
return
self._woke_from_suspend = True
_logger.info(
"Resumed from suspend (~%.0fs); dropping stale host tunnel to reconnect",
gap_s,
)
transport = getattr(ws, "transport", None)
if transport is not None:
with contextlib.suppress(Exception):
transport.abort()
def _cleanup_runners(self) -> None:
"""Terminate all live runners on shutdown.
@@ -2842,6 +2981,11 @@ class HostProcess:
additional_headers=headers,
max_size=100 * 1024 * 1024,
ssl=ssl_ctx,
open_timeout=(
_RECONNECT_OPEN_TIMEOUT_S
if self._ever_connected
else _INITIAL_CONNECT_OPEN_TIMEOUT_S
),
# Align the host->server tunnel's protocol keepalive to the same
# 90 s app-level budget as the runner tunnel (not the 20 s library
# default that drops a busy-but-healthy tunnel with 1011 — #1116).
@@ -3002,7 +3146,18 @@ class HostProcess:
flush=True,
)
# Readiness refresh runs in its own task, never on this receive loop:
# a harness probe that blocks (a hung CLI ``--version`` / ``auth
# status``) must not delay ``ws.recv()`` or the inline keepalive pong
# the server's watchdog counts as liveness, or it closes the tunnel
# with ``4003 ping timeout``.
readiness_task = asyncio.create_task(self._harness_readiness_loop(ws))
# Warm the pre-launch model listings once a server can actually ask
# for them, so the first picker open is served from cache instead of
# waiting on a harness probe. Cache-fresh reconnects are a no-op.
prewarm_task = asyncio.create_task(
self._prewarm_model_options(), name="host-model-options-prewarm"
)
try:
while True:
raw = await ws.recv()
@@ -3023,6 +3178,9 @@ class HostProcess:
# _runner_lifecycle_lock in _dispatch_host_frame.
self._start_frame_task(ws, raw)
finally:
prewarm_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await prewarm_task
readiness_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await readiness_task
@@ -3231,7 +3389,20 @@ class HostProcess:
fs_result = await asyncio.to_thread(self._handle_fs_request, frame)
await ws.send(encode_host_frame(fs_result))
elif isinstance(frame, HostModelOptionsFrame):
await ws.send(encode_host_frame(await self._handle_model_options(frame)))
# Every dispatched frame already runs on its own task (see
# _start_frame_task), so a cold harness probe here cannot stall
# the receive loop — answer inline, with a crash converted to an
# honest failed frame so the server's request future settles.
try:
options_result = await self._handle_model_options(frame)
except Exception:
_logger.exception("Model options resolution crashed for %r", frame.harness)
options_result = HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error=f"model options resolution crashed for {frame.harness!r}",
)
await ws.send(encode_host_frame(options_result))
def run_host_process(
+130 -33
View File
@@ -93,6 +93,11 @@ class _PolicyVerdict(Protocol):
_PolicyEvaluator: TypeAlias = Callable[[str, _AcpJsonObject], Awaitable[_PolicyVerdict]]
_ElicitationHandler: TypeAlias = Callable[[str, _AcpJsonObject], Awaitable[bool]]
# Choice-aware elicitation: offers the agent's own permission options and returns
# the chosen label (``None`` = declined). Optional; falls back to the yes/no form.
_ElicitationChoiceHandler: TypeAlias = Callable[
[str, _AcpJsonObject, Sequence[str]], Awaitable[str | None]
]
_ToolExecutor: TypeAlias = Callable[[str, _AcpJsonObject], Awaitable[_AcpJsonObject]]
# ACP error code an agent maps to a filesystem "not found" (ENOENT) when a
@@ -185,6 +190,11 @@ class AcpAgentConfig:
the agent authenticates with an agent that reads a variable must name
it here (or in ``os_env.sandbox.env_passthrough``) or it starts
unauthenticated. Names only; values come from the host environment.
:param permission_mode: Omnigent permission stance, e.g. ``"auto"``
(default) or ``"bypassPermissions"``. Only the latter changes anything:
it skips the human approval card for a request no policy had an opinion
on, matching claude-sdk's ``can_use_tool`` gate. Policy still runs in
every mode, so a DENY still blocks and an explicit ASK still prompts.
"""
command: str
@@ -194,6 +204,7 @@ class AcpAgentConfig:
send_model_in_session_new: bool = False
omnigent_mcp: bool = True
env_passthrough: tuple[str, ...] = ()
permission_mode: str = "auto"
class _AcpRequestError(Exception):
@@ -348,6 +359,7 @@ class AcpExecutor(Executor):
# wired (standalone / unit tests) → permission falls back to allow.
self._policy_evaluator: _PolicyEvaluator | None = None
self._elicitation_handler: _ElicitationHandler | None = None
self._elicitation_choice_handler: _ElicitationChoiceHandler | None = None
# Adapter-injected tool-execution bridge (the same ``_tool_executor``
# attribute the SDK harnesses use); backs the Omnigent MCP relay.
self._tool_executor: _ToolExecutor | None = None
@@ -716,8 +728,8 @@ class AcpExecutor(Executor):
error: _AcpJsonObject | None = None
try:
if method == _AGENT_REQUEST_REQUEST_PERMISSION:
allow = await self._decide_permission(params)
result = self._permission_outcome(params, allow=allow)
allow, option_id = await self._decide_permission(params)
result = self._permission_outcome(params, allow=allow, option_id=option_id)
elif method == "fs/read_text_file" and self._fs_delegation:
result = await self._handle_fs_read(params)
elif method == "fs/write_text_file" and self._fs_delegation:
@@ -831,23 +843,106 @@ class AcpExecutor(Executor):
args = cached if isinstance(cached, dict) else {}
return str(name), args
async def _decide_permission(self, params: _AcpJsonObject) -> bool:
"""Decide allow/deny for a permission request — policy then elicitation.
@property
def _bypass_permissions(self) -> bool:
"""Whether the user opted out of approval cards for this agent.
Mirrors :class:`~omnigent.inner.claude_sdk_executor.ClaudeSDKExecutor`'s
``can_use_tool`` stance: ``"bypassPermissions"`` and nothing else, so the ``"auto"``
default keeps prompting. (Cursor also treats ``"auto"`` as no-prompt;
ACP agents ask only about actions they consider permission-worthy, so
silencing the default would drop meaningful prompts.)
"""
return self._config.permission_mode == "bypassPermissions"
@staticmethod
def _permission_options(params: _AcpJsonObject) -> list[_AcpJsonObject]:
"""The agent's offered options, each an ``{optionId, name, kind}`` dict."""
return [o for o in (params.get("options") or []) if isinstance(o, dict)]
def _scoped_options(self, params: _AcpJsonObject) -> list[tuple[str, _AcpJsonObject]] | None:
"""Label the agent's options for a choice card, or ``None`` if unusable.
Unusable means: fewer than two options, a blank or duplicated label (the
reply names the label, so duplicates are ambiguous), or no ``reject_*``
option a choice card replaces the Approve/Reject buttons, so without one
the user would have no way to say no.
"""
labeled = [(str(o.get("name") or "").strip(), o) for o in self._permission_options(params)]
if len(labeled) < 2 or any(not name for name, _ in labeled):
return None
labels = [name for name, _ in labeled]
if len(set(labels)) != len(labels):
return None
if not any("reject" in str(o.get("kind", "")) for _, o in labeled):
return None
return labeled
async def _ask_user(
self, tool_name: str, tool_input: _AcpJsonObject, params: _AcpJsonObject
) -> tuple[bool, str | None]:
"""Route a permission request to the user; return ``(allowed, option_id)``.
Prefers the choice bridge, which puts the agent's *own* options on the
card: picking "allow this command for the session" is one click the agent
then honors itself, so the same command class stops re-prompting. Falls
back to the yes/no bridge, whose grant stays once-scoped.
"""
choice_handler = self._elicitation_choice_handler
labeled = self._scoped_options(params) if choice_handler is not None else None
if choice_handler is not None and labeled is not None:
chosen = await choice_handler(tool_name, tool_input, [name for name, _ in labeled])
if chosen is None:
return False, None
picked = next((o for name, o in labeled if name == chosen), None)
if picked is None:
logger.warning(
"acp permission choice %r was not offered; denying tool=%s", chosen, tool_name
)
return False, None
option_id = picked.get("optionId")
allowed = "allow" in str(picked.get("kind", ""))
logger.info(
"acp permission %s by user (scope=%s): tool=%s",
"allowed" if allowed else "denied",
option_id,
tool_name,
)
return allowed, (option_id if isinstance(option_id, str) else None)
handler = self._elicitation_handler
if handler is None:
return False, None
return bool(await handler(tool_name, tool_input)), None
async def _decide_permission(self, params: _AcpJsonObject) -> tuple[bool, str | None]:
"""Decide a permission request — policy then elicitation.
1. **TOOL_CALL policy** (:attr:`_policy_evaluator`): a hard
``POLICY_ACTION_DENY`` denies; ``POLICY_ACTION_ASK`` defers to
elicitation (and **fails closed** when no handler is wired);
``ALLOW`` / unspecified falls through.
2. **Human-consent elicitation** (:attr:`_elicitation_handler`): routes
to the user via a web approval card and returns their accept/deny.
2. **Human-consent elicitation**: the agent's own options via
:attr:`_elicitation_choice_handler`, else a yes/no card via
:attr:`_elicitation_handler`. Skipped under
``permission_mode="bypassPermissions"`` but only for a request no
policy had an opinion on, so a DENY still blocks and a policy that
says ASK still prompts.
When neither bridge is wired (standalone / unit tests), falls back to
allow so direct use of the executor isn't blocked. In normal runner
operation the adapter installs both, so destructive actions are gated.
:returns: ``(allowed, option_id)`` *option_id* is the scope the user
picked from the agent's options, or ``None`` to let
:meth:`_permission_outcome` choose the narrowest grant.
"""
tool_name, tool_input = self._extract_tool_call(params)
handler = getattr(self, "_elicitation_handler", None)
policy_eval = getattr(self, "_policy_evaluator", None)
# Either bridge can carry the question to the user.
can_ask = (
self._elicitation_handler is not None or self._elicitation_choice_handler is not None
)
if policy_eval is not None:
action: str | None
@@ -861,45 +956,47 @@ class AcpExecutor(Executor):
action = None
if action == "POLICY_ACTION_DENY":
logger.info("acp permission denied by policy: tool=%s", tool_name)
return False
return False, None
if action == "POLICY_ACTION_ASK":
if handler is None:
if not can_ask:
logger.warning(
"acp TOOL_CALL policy ASK with no elicitation handler; denying tool=%s",
tool_name,
)
return False
allowed = bool(await handler(tool_name, tool_input))
logger.info(
"acp permission %s by user (policy ASK): tool=%s",
"allowed" if allowed else "denied",
tool_name,
)
return allowed
return False, None
return await self._ask_user(tool_name, tool_input, params)
# ALLOW / UNSPECIFIED / unknown → fall through to elicitation.
if handler is not None:
allowed = bool(await handler(tool_name, tool_input))
logger.info(
"acp permission %s by user: tool=%s",
"allowed" if allowed else "denied",
tool_name,
)
return allowed
if can_ask and not self._bypass_permissions:
return await self._ask_user(tool_name, tool_input, params)
if can_ask:
# bypassPermissions: no policy had an opinion and the user asked not
# to be prompted. Logged at info so the audit trail still names what
# ran unreviewed. Answered per-request (never the agent's own bypass
# option) so every later call stays visible to policy.
logger.info("acp permission allowed (bypassPermissions): tool=%s", tool_name)
return True, None
logger.debug("acp permission allowed (no policy/elicitation wired): tool=%s", tool_name)
return True
return True, None
@staticmethod
def _permission_outcome(params: _AcpJsonObject, *, allow: bool) -> _AcpJsonObject:
"""Map an allow/deny decision to an ACP permission ``outcome``.
def _permission_outcome(
params: _AcpJsonObject, *, allow: bool, option_id: str | None = None
) -> _AcpJsonObject:
"""Map a decision to an ACP permission ``outcome``.
On allow, prefer a once-scoped grant (``allow_once``) over
``allow_always`` so we never persist a blanket "always allow". On deny,
pick a ``reject_*`` option, or ``cancelled`` when none is offered. The
agent's options carry both ``optionId`` and ``kind`` (e.g. ``allow_once``).
*option_id* is a scope the user picked from the agent's own options; it is
echoed only after confirming the agent offered it, so we never send an id
it doesn't know. Without one: on allow prefer a once-scoped grant
(``allow_once``) over ``allow_always``, so a blanket "always allow" is
only ever sent because the user chose it; on deny pick a ``reject_*``
option, or ``cancelled`` when none is offered. The agent's options carry
both ``optionId`` and ``kind`` (e.g. ``allow_once``).
"""
options = [o for o in (params.get("options") or []) if isinstance(o, dict)]
options = AcpExecutor._permission_options(params)
if option_id is not None and any(o.get("optionId") == option_id for o in options):
return {"outcome": {"outcome": "selected", "optionId": option_id}}
def _pick(*kinds: str) -> _AcpJsonObject | None:
for kind in kinds:
+7
View File
@@ -34,6 +34,9 @@ Env vars read at startup:
value is read from this process's own environment.
- ``HARNESS_ACP_PROMPT_TIMEOUT_S``: optional idle (time-without-progress) deadline in
seconds for a prompt turn (default 300); must be positive and finite or the child aborts.
- ``HARNESS_ACP_PERMISSION_MODE``: Omnigent permission stance, ``auto`` (default) or
``bypassPermissions`` the latter skips the approval card for a tool call no
policy had an opinion on, so a headless agent runs without parking on prompts.
"""
from __future__ import annotations
@@ -60,6 +63,8 @@ _ENV_OMNIGENT_MCP = "HARNESS_ACP_OMNIGENT_MCP"
_ENV_CWD = "HARNESS_ACP_CWD"
_ENV_OS_ENV = "HARNESS_ACP_OS_ENV"
_ENV_ENV_PASSTHROUGH = "HARNESS_ACP_ENV_PASSTHROUGH"
_ENV_PERMISSION_MODE = "HARNESS_ACP_PERMISSION_MODE"
_DEFAULT_PERMISSION_MODE = "auto"
def _env_enabled(name: str, *, default: bool) -> bool:
@@ -127,6 +132,7 @@ def _build_acp_executor() -> Executor:
send_model = _env_enabled(_ENV_SEND_MODEL, default=False)
omnigent_mcp = _env_enabled(_ENV_OMNIGENT_MCP, default=True)
cwd = os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE") or None
permission_mode = os.environ.get(_ENV_PERMISSION_MODE, "").strip() or _DEFAULT_PERMISSION_MODE
config = AcpAgentConfig(
command=command,
@@ -136,6 +142,7 @@ def _build_acp_executor() -> Executor:
send_model_in_session_new=send_model,
omnigent_mcp=omnigent_mcp,
env_passthrough=_env_passthrough_names(),
permission_mode=permission_mode,
)
return AcpExecutor(config=config, cwd=cwd, os_env=_resolve_os_env())
+21
View File
@@ -13,8 +13,11 @@ from omnigent.claude_native_bridge import (
BRIDGE_DIR_ENV_VAR,
REQUEST_SESSION_ID_ENV_VAR,
SWITCH_MODEL_DIALOG_HINT,
ClaudePromptTimeout,
TmuxSessionNotAdvertised,
inject_slash_command,
inject_user_message,
kill_session,
read_active_session_id,
read_claude_status_model,
read_launch_model,
@@ -192,11 +195,29 @@ class ClaudeNativeExecutor(Executor):
self._bridge_dir,
content=text,
)
except ClaudePromptTimeout as exc:
cleanup_error = self._reap_failed_turn()
message = describe_exception(exc)
if cleanup_error is not None:
message = f"{message} Cleanup also failed: {cleanup_error}"
yield ExecutorError(message=message)
return
except RuntimeError as exc:
yield ExecutorError(message=describe_exception(exc))
return
yield TurnComplete(response=None)
def _reap_failed_turn(self) -> str | None:
"""Kill the Claude pane before a delivery timeout becomes ``failed``."""
try:
kill_session(self._bridge_dir, timeout_s=1.0)
except TmuxSessionNotAdvertised:
_logger.debug("claude-native: timed-out session already disappeared")
except RuntimeError as exc:
_logger.warning("claude-native: failed to reap timed-out session", exc_info=True)
return describe_exception(exc)
return None
def _model_command_arg(self, wanted_model: str | None) -> str | None:
"""
Return the ``/model`` argument for this turn, or ``None`` to skip.
+140 -6
View File
@@ -35,6 +35,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, TypeAlias, cast
from omnigent import _native_forwarder_health as native_forwarder_health
from omnigent import model_catalog
from omnigent._platform import resolve_cli_binary
from omnigent.codex_model_vocabulary import (
@@ -115,6 +116,11 @@ CodexToolExecutor: TypeAlias = Callable[
# but keep waiting — a long-running tool or model call can legitimately
# block events far longer than any fixed deadline.
_TURN_EVENT_WARN_SECONDS = 600.0
# The idle wait polls on this shorter interval so a fatal gateway error the
# stderr loop sets mid-wait is acted on promptly, rather than only when the
# 600s warn window elapses. Chosen to divide the warn window evenly so the
# warning cadence is unchanged.
_TURN_EVENT_POLL_SECONDS = 5.0
_TURN_COMPLETED_DRAIN_SECONDS = 1.0
# Wall-clock budget for the ``codex --version`` probe. A broken codex
# build that blocks (e.g. on stdin) must not stall session startup — on
@@ -153,6 +159,62 @@ _CODEX_PROVIDER_CONFIG_PREFIX = "model_providers."
# developer API key that would charge separately.
_CODEX_ENV_DENY_EXACT: frozenset[str] = frozenset({"OPENAI_API_KEY"})
# The codex CLI logs a rejected gateway request to stderr as
# ``unexpected status <code> <reason>: {...}, url: <url>`` and precedes it with
# ``Reconnecting... N/5`` retry lines. These parse that shape so the head can
# attribute the real gateway error to a turn that otherwise emits no events.
_CODEX_STDERR_STATUS_RE = re.compile(
r"unexpected status (?P<code>\d{3})(?:\s+(?P<reason>[A-Za-z][A-Za-z ]*?))?\s*[:,]"
)
_CODEX_STDERR_URL_RE = re.compile(r"url:\s*(?P<url>\S+)")
_CODEX_STDERR_RETRY_EXHAUSTED_RE = re.compile(
r"Reconnecting\.{0,3}\s*(?P<n>\d+)\s*/\s*(?P<total>\d+)"
)
# HTTP statuses that are not transient — a retry can never fix them, so the
# head fails the turn fast instead of riding the full idle watchdog.
_CODEX_STDERR_FATAL_STATUSES: frozenset[int] = frozenset({401, 403})
class _CodexGatewayError:
"""A parsed gateway rejection read off the codex CLI's stderr.
``code`` is the HTTP status; ``fatal`` marks an auth-class status that a
retry cannot fix (the turn should fail fast rather than stall).
"""
__slots__ = ("code", "fatal", "reason", "url")
def __init__(self, code: int, reason: str | None, url: str | None) -> None:
self.code = code
self.reason = reason
self.url = url
self.fatal = code in _CODEX_STDERR_FATAL_STATUSES
def detail(self, *, model: str | None = None) -> str:
"""A concise, actionable one-line cause for the turn-failure message."""
reason = f" {self.reason}" if self.reason else ""
target = f" for {model}" if model else ""
where = f" at {self.url}" if self.url else ""
hint = " (auth likely expired/misconfigured)" if self.fatal else ""
return f"gateway returned {self.code}{reason}{target}{where}{hint}"
def _parse_codex_gateway_error(line: str) -> _CodexGatewayError | None:
"""Return a parsed gateway rejection from a codex stderr *line*, else None.
Only lines carrying an ``unexpected status <code>`` shape are classified;
ordinary stderr (including the ``Reconnecting`` retry lines) returns None.
"""
match = _CODEX_STDERR_STATUS_RE.search(line)
if match is None:
return None
code = int(match.group("code"))
raw_reason = match.group("reason")
reason = raw_reason.strip() if raw_reason else None
url_match = _CODEX_STDERR_URL_RE.search(line)
url = url_match.group("url").rstrip(",") if url_match else None
return _CodexGatewayError(code, reason, url)
def _extract_codex_last_turn_usage(params: object, model: str | None) -> dict[str, object] | None:
"""Map a ``thread/tokenUsage/updated`` payload's ``last`` breakdown
@@ -2042,6 +2104,16 @@ class _CodexAppServerSession:
# field (it is silently dropped), hence the separate settings update.
self._applied_effort: str | None = None
self._recent_stderr: list[str] = []
# Auth-class gateway rejection tracking, read off the CLI's stderr so a
# turn that emits no events fails fast with the real cause instead of
# stalling to the idle watchdog. ``_pending`` holds a parsed fatal
# (401/403) rejection; ``_saw_retries_exhausted`` records a final
# ``Reconnecting N/N``. Fast-fail arms (``_fatal_gateway_error``) only
# when BOTH are seen, so a blip the CLI recovers from can't kill a
# healthy turn. All three reset at turn start.
self._pending_fatal_gateway_error: _CodexGatewayError | None = None
self._saw_retries_exhausted = False
self._fatal_gateway_error: _CodexGatewayError | None = None
self._recent_events: list[CodexMessage] = []
self._process_cwd: Path | None = None
# Private CODEX_HOME so the subprocess never writes to the user's ~/.codex/.
@@ -2051,6 +2123,9 @@ class _CodexAppServerSession:
# on the next ``turn/completed`` so each TurnComplete carries the
# usage for the turn that just finished.
self._last_turn_usage: dict[str, object] | None = None
# Serialize concurrent writes to the subprocess stdin so that parallel
# tool-call responses don't interleave bytes on the pipe.
self._stdin_lock = asyncio.Lock()
async def start(self) -> None:
if self._started:
@@ -2335,6 +2410,14 @@ class _CodexAppServerSession:
await self.start()
assert self._proc is not None
# Fresh turn: forget any prior turn's gateway-error signals and clear
# the shared watchdog slot so a resolved earlier failure can't be
# misattributed to this turn.
self._pending_fatal_gateway_error = None
self._saw_retries_exhausted = False
self._fatal_gateway_error = None
native_forwarder_health.note_post_success()
is_new_thread = self.thread_id is None
if is_new_thread:
params: CodexParams = {
@@ -2498,14 +2581,30 @@ class _CodexAppServerSession:
while True:
event_task = asyncio.ensure_future(self._events.get())
idle_seconds = 0.0
seconds_since_warn = 0.0
fatal_gateway_error: _CodexGatewayError | None = None
# Poll on the shorter of the two intervals so a fatal gateway
# error set mid-wait is acted on promptly; when a test shrinks
# the warn window below the poll interval, poll at the warn
# window so the warning cadence is preserved.
poll_interval = min(_TURN_EVENT_POLL_SECONDS, _TURN_EVENT_WARN_SECONDS)
try:
while True:
done, _ = await asyncio.wait(
{event_task}, timeout=_TURN_EVENT_WARN_SECONDS
)
done, _ = await asyncio.wait({event_task}, timeout=poll_interval)
if event_task in done:
break
idle_seconds += _TURN_EVENT_WARN_SECONDS
# The stderr loop sets this once the CLI has exhausted
# its retries on an auth-class gateway rejection. Fail
# fast with the real cause rather than stalling to the
# idle watchdog on a turn that will never emit events.
if self._fatal_gateway_error is not None:
fatal_gateway_error = self._fatal_gateway_error
break
idle_seconds += poll_interval
seconds_since_warn += poll_interval
if seconds_since_warn < _TURN_EVENT_WARN_SECONDS:
continue
seconds_since_warn = 0.0
pending_tool_summaries = [
{
"call_id": call_id,
@@ -2531,6 +2630,18 @@ class _CodexAppServerSession:
with suppress(BaseException):
await event_task
raise
if fatal_gateway_error is not None:
event_task.cancel()
with suppress(BaseException):
await event_task
try:
await asyncio.wait_for(self.interrupt_turn(), timeout=0.5)
except Exception as exc: # noqa: BLE001 — interrupt is best-effort
logger.debug("Codex auth-failure turn interrupt failed: %s", exc)
yield ExecutorError(
message=fatal_gateway_error.detail(model=model), retryable=False
)
return
message = event_task.result()
self._record_event(message)
@@ -2839,8 +2950,9 @@ class _CodexAppServerSession:
async def _send_message(self, payload: CodexMessage) -> None:
assert self._proc is not None and self._proc.stdin is not None
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
await self._proc.stdin.drain()
async with self._stdin_lock:
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
await self._proc.stdin.drain()
@staticmethod
async def _iter_stream_chunks(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
@@ -2901,9 +3013,31 @@ class _CodexAppServerSession:
if len(self._recent_stderr) > 20:
self._recent_stderr.pop(0)
logger.debug("codex app-server stderr: %s", text)
self._note_stderr_gateway_error(text)
except asyncio.CancelledError:
raise
def _note_stderr_gateway_error(self, text: str) -> None:
"""Attribute (and, when fatal, arm fast-fail for) a gateway rejection.
The gateway cause is recorded into the shared watchdog slot the moment
it is seen, so a stalled turn surfaces the real error. An auth-class
(401/403) rejection arms fast-fail only once the CLI has also exhausted
its own retry budget (a final ``Reconnecting N/N``); the two signals can
arrive in either order, so both are tracked and fast-fail arms when both
hold a single blip the CLI recovers from never kills a healthy turn.
"""
retry = _CODEX_STDERR_RETRY_EXHAUSTED_RE.search(text)
if retry is not None and retry.group("n") == retry.group("total"):
self._saw_retries_exhausted = True
error = _parse_codex_gateway_error(text)
if error is not None:
native_forwarder_health.record_transport_failure(error.detail())
if error.fatal:
self._pending_fatal_gateway_error = error
if self._pending_fatal_gateway_error is not None and self._saw_retries_exhausted:
self._fatal_gateway_error = self._pending_fatal_gateway_error
@dataclass
class _CodexSessionState:
+12 -2
View File
@@ -40,7 +40,11 @@ from omnigent.inner.native_attachments import (
parse_data_uri,
unresolved_attachment_marker,
)
from omnigent.reasoning_effort import CODEX_EFFORTS, effort_for_model_switch, validate_effort
from omnigent.reasoning_effort import (
CODEX_NATIVE_EFFORTS,
effort_for_model_switch,
validate_effort,
)
_logger = logging.getLogger(__name__)
@@ -319,6 +323,12 @@ class CodexNativeExecutor(Executor):
turn_params: dict[str, object] = {
"threadId": state.thread_id,
"input": input_items,
"environments": [
{
"environmentId": "local",
"cwd": state.cwd or str(Path.cwd()),
}
],
}
response = await client.request("turn/start", turn_params)
result = _json_object(response.get("result"))
@@ -373,7 +383,7 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
overrides["model"] = model
raw_effort = config.extra.get("reasoning_effort")
try:
effort = validate_effort(raw_effort, "codex", CODEX_EFFORTS)
effort = validate_effort(raw_effort, "codex", CODEX_NATIVE_EFFORTS)
except ValueError:
# A bad effort must not sink the turn — drop it and keep Codex's
# current effort rather than failing the whole dispatch.
+20 -7
View File
@@ -93,6 +93,7 @@ RawToolItem: TypeAlias = Any # type: ignore[explicit-any]
# an optional import at type-check time — the executor only constructs
# one when instantiated.
AsyncOpenAIClient: TypeAlias = Any # type: ignore[explicit-any]
ReasoningItemIdPolicy: TypeAlias = Literal["preserve", "omit"]
# Tool executor callable wired in by ``omnigent.Session``. The result
# is JSON-ish (dict[str, Any]) but the static type leaks ``Any`` through
@@ -1026,6 +1027,7 @@ class OpenAIAgentsSDKExecutor(Executor):
base_url_override: str | None = None,
gateway_host: str | None = None,
gateway_auth_command: str | None = None,
reasoning_item_id_policy: ReasoningItemIdPolicy | None = None,
) -> None:
"""Create an OpenAIAgentsSDKExecutor.
@@ -1071,7 +1073,15 @@ class OpenAIAgentsSDKExecutor(Executor):
``"databricks auth token --host https://example.databricks.com ..."``
or ``"printf %s sk-..."``. Set from
``HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND``.
:param reasoning_item_id_policy: Optional Responses API replay policy.
``"preserve"`` retains reasoning item IDs; ``"omit"`` is available
for legacy providers that reject orphaned reasoning items. ``None``
leaves the setting unspecified and uses the SDK default.
:raises ValueError: If *reasoning_item_id_policy* is not ``"preserve"``
or ``"omit"``.
"""
if reasoning_item_id_policy not in (None, "preserve", "omit"):
raise ValueError("reasoning_item_id_policy must be 'preserve', 'omit', or unset")
self._retry_policy = retry_policy if retry_policy is not None else RetryPolicy()
raw_client = (
client
@@ -1100,6 +1110,7 @@ class OpenAIAgentsSDKExecutor(Executor):
self._profile = profile
self._use_responses = use_responses
self._model_override = model
self._reasoning_item_id_policy = reasoning_item_id_policy
self._databricks = _is_databricks_openai_client(self._client)
self._tool_executor: ToolExecutor | None = None
self._session_states: dict[str, _AgentsSessionState] = {}
@@ -1525,13 +1536,15 @@ class OpenAIAgentsSDKExecutor(Executor):
max_tokens=max_tokens,
)
current_item_count = len(await state.sdk_session.get_items())
run_config = agents_sdk.RunConfig(
model=model,
model_provider=provider,
tracing_disabled=True,
reasoning_item_id_policy="omit",
call_model_input_filter=self._filter_model_input,
)
run_config_kwargs: dict[str, object] = {
"model": model,
"model_provider": provider,
"tracing_disabled": True,
"call_model_input_filter": self._filter_model_input,
}
if self._reasoning_item_id_policy is not None:
run_config_kwargs["reasoning_item_id_policy"] = self._reasoning_item_id_policy
run_config = agents_sdk.RunConfig(**run_config_kwargs)
max_turns = 1 if stepwise_internal_turns else int(cfg.extra.get("max_turns", 1000))
# ── LLM_REQUEST policy evaluation ────────────────────────
+14 -1
View File
@@ -83,17 +83,24 @@ Env vars read at startup:
default. An explicit env-var value still wins as the
highest-priority switch, so bad specs fail loudly at the gateway
instead of being silently rewritten.
- ``HARNESS_OPENAI_AGENTS_REASONING_ITEM_ID_POLICY``: optional Responses
replay policy, either ``"preserve"`` or ``"omit"``. Unset uses the
OpenAI Agents SDK default.
"""
from __future__ import annotations
import logging
import os
from typing import cast
from fastapi import FastAPI
from omnigent.inner.executor import Executor
from omnigent.inner.openai_agents_sdk_executor import OpenAIAgentsSDKExecutor
from omnigent.inner.openai_agents_sdk_executor import (
OpenAIAgentsSDKExecutor,
ReasoningItemIdPolicy,
)
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
_logger = logging.getLogger(__name__)
@@ -107,6 +114,7 @@ _ENV_GATEWAY_HOST = "HARNESS_OPENAI_AGENTS_GATEWAY_HOST"
_ENV_USE_RESPONSES = "HARNESS_OPENAI_AGENTS_USE_RESPONSES"
_ENV_GATEWAY_BASE_URL = "HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL"
_ENV_GATEWAY_AUTH_COMMAND = "HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND"
_ENV_REASONING_ITEM_ID_POLICY = "HARNESS_OPENAI_AGENTS_REASONING_ITEM_ID_POLICY"
# Direct OpenAI-compatible API key set when the agent spec declares
# executor.auth: {type: api_key, api_key: …}. Takes precedence over
# ambient OPENAI_API_KEY in the caller's environment.
@@ -210,6 +218,10 @@ def _build_openai_agents_sdk_executor() -> Executor:
_ENV_USE_RESPONSES,
default=default_use_responses,
)
reasoning_item_id_policy = cast(
ReasoningItemIdPolicy | None,
os.environ.get(_ENV_REASONING_ITEM_ID_POLICY) or None,
)
return OpenAIAgentsSDKExecutor(
profile=profile,
api_key=api_key,
@@ -218,6 +230,7 @@ def _build_openai_agents_sdk_executor() -> Executor:
base_url_override=os.environ.get(_ENV_GATEWAY_BASE_URL) or None,
gateway_host=os.environ.get(_ENV_GATEWAY_HOST) or None,
gateway_auth_command=os.environ.get(_ENV_GATEWAY_AUTH_COMMAND) or None,
reasoning_item_id_policy=reasoning_item_id_policy,
)
+6 -2
View File
@@ -294,8 +294,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
Build tmux options for scrollback and pane input behavior.
``history-limit`` is generated per terminal because it comes from
``TerminalEnvSpec.scrollback``. ``mouse on`` makes the attached web
terminal scrollable. ``focus-events on`` lets interactive programs
``TerminalEnvSpec.scrollback``. ``set-clipboard external`` exports tmux
copy-mode selections without trusting pane OSC 52 requests. ``mouse on``
makes the attached web terminal scrollable. ``focus-events on`` lets interactive programs
observe pane focus changes. ``extended-keys`` with CSI-u formatting
lets programs inside tmux receive Kitty Keyboard Protocol keys such
as Shift+Enter when the attached terminal supports them. Terminals
@@ -311,6 +312,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
["set-option", "-g", "history-limit", str(scrollback)],
["set-option", "-sq", "extended-keys", "on"],
["set-option", "-sq", "extended-keys-format", "csi-u"],
# Export tmux copy-mode selections to attached terminals without letting
# pane applications create tmux buffers through OSC 52.
["set-option", "-sq", "set-clipboard", "external"],
["set-option", "-g", "mouse", "on"],
["set-option", "-g", "focus-events", "on"],
["set-option", "-g", "escape-time", "0"],
+9 -5
View File
@@ -333,10 +333,13 @@ async def _prepare_kimi_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Kimi session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Kimi session...")
session_id = await _create_kimi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_kimi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Kimi session...")
@@ -385,7 +388,8 @@ async def _prepare_kimi_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -364,10 +364,13 @@ async def _prepare_kiro_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Kiro session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Kiro session...")
session_id = await _create_kiro_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_kiro_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Kiro session...")
@@ -409,7 +412,8 @@ async def _prepare_kiro_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+15 -29
View File
@@ -46,7 +46,6 @@ from cachetools import TTLCache
from omnigent._platform import default_shell_argv
from omnigent.json_types import JsonObject as _JsonObject
from omnigent.llms.anthropic_model_metadata import parse_anthropic_model_metadata
from omnigent.model_fallbacks import StaticModelFallback, static_model_fallback
from omnigent.model_metadata import (
ModelCapability,
ModelCostTier,
@@ -206,15 +205,12 @@ class ModelListing:
:param models: The enumerated models, e.g.
``(ModelEntry(id="databricks-gpt-5-4", family="openai"),)``.
:param note: Human-readable provenance / failure explanation.
:param static_fallback: Ownership metadata for a release-curated fallback;
``None`` for live or empty listings.
"""
source: str
verified: bool
models: tuple[ModelEntry, ...]
note: str
static_fallback: StaticModelFallback | None = None
@dataclass(frozen=True)
@@ -897,8 +893,7 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
"""Serialize a :class:`ModelListing` into the tool's JSON row shape.
:param listing: The listing to serialize.
:returns: Row dict; ``context_window`` and ``static_fallback`` appear only
when known.
:returns: Row dict; ``context_window`` appears only when known.
"""
models: list[_JsonObject] = []
for entry in listing.models:
@@ -934,12 +929,6 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
"models": models,
"note": listing.note,
}
if listing.static_fallback is not None:
payload["static_fallback"] = {
"owner": listing.static_fallback.owner,
"provenance": listing.static_fallback.provenance,
"discovery_gap": listing.static_fallback.discovery_gap,
}
return payload
@@ -1061,23 +1050,24 @@ def _fetch_cursor_cli_listing(provider: ResolvedModelProvider) -> ModelListing:
def _static_subscription_listing(provider: ResolvedModelProvider) -> ModelListing:
"""Build the curated static listing for a subscription CLI login.
"""Build the (empty) pre-launch listing for a subscription CLI login.
Subscription logins expose no model-listing API, and the curated
stand-ins this used to serve are gone the live harness probes are the
source of truth, so a path that cannot probe reports nothing rather
than a plausible-but-stale list.
:param provider: A ``kind="subscription"`` provider descriptor.
:returns: A ``source="static"`` listing with ``verified=False``.
:returns: A ``source="static"`` listing with no models.
"""
fallback = static_model_fallback(SUBSCRIPTION_KIND, provider.cli or "")
ids = fallback.model_ids if fallback is not None else ()
return ModelListing(
source="static",
verified=False,
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
models=(),
note=(
f"curated aliases for the {provider.cli or 'unknown'} CLI login "
"(subscription logins expose no model-listing API; availability "
"depends on the logged-in plan)"
f"the {provider.cli or 'unknown'} CLI login exposes no model-listing "
"API before launch; the live listing comes from probing the harness"
),
static_fallback=fallback,
)
@@ -1091,20 +1081,16 @@ def _static_cli_config_listing(provider: ResolvedModelProvider) -> ModelListing:
resolve not a "no credentials" preflight failure.
:param provider: A ``kind="cli-config"`` provider descriptor.
:returns: A ``source="static"`` listing with ``verified=False``.
:returns: A ``source="static"`` listing with no models.
"""
fallback = static_model_fallback(CLI_CONFIG_KIND, provider.cli or "")
ids = fallback.model_ids if fallback is not None else ()
return ModelListing(
source="static",
verified=False,
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
models=(),
note=(
f"curated ids for {provider.detail}; its credential lives in the "
"CLI's own config file and is resolved by the CLI at launch, so "
"it cannot be verified from here"
f"{provider.detail} enumerates its models only from the CLI's own "
"config at launch; the live listing comes from probing the harness"
),
static_fallback=fallback,
)
+203
View File
@@ -0,0 +1,203 @@
"""The shared on-disk model-catalog store (model-flows-design.md §1.2).
One probe result, many consumers: whoever ran a harness's ``list_models``
(the host at boot, the runner at launch when the file is absent, a live
codex session writing back) persists the catalog here, keyed by harness and
a launch-config fingerprint, and every surface the pre-launch picker, the
in-session gear, launch resolution and validation reads the same bytes.
Because writer and readers share one file, host/runner drift and
probe-vs-session mismatch are impossible by construction.
The store holds only verbatim harness answers; nothing else ever writes it.
A fingerprint mismatch is a miss (never a "close enough" hit), so an answer
probed under one config can never serve another.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
import tempfile
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
def fingerprint_of(*parts: object) -> str:
"""
Stable fingerprint of a resolved harness configuration.
:param parts: Hashable configuration facets resolved overrides, env
pairs, binary identity. Stringified in order.
:returns: A short hex digest.
"""
digest = hashlib.sha256()
for part in parts:
digest.update(repr(part).encode("utf-8"))
digest.update(b"\x00")
return digest.hexdigest()[:16]
#: Catalog entries older than this get a background refresh on read (the
#: readers decide; the store only reports staleness).
CATALOG_STALE_AFTER_S = 3600.0
def _data_dir() -> Path:
"""Return the omnigent data dir (must stay in lock-step with
``omnigent.host.local_server._local_data_dir`` /
``omnigent.chat._omnigent_persistent_dir``).
:returns: ``$OMNIGENT_DATA_DIR`` when set, else ``~/.omnigent``.
"""
value = os.environ.get("OMNIGENT_DATA_DIR")
if value:
return Path(value).expanduser()
return Path.home() / ".omnigent"
def catalog_path(harness: str, fingerprint: str) -> Path:
"""Return the catalog file path for one (harness, fingerprint).
:param harness: Canonical harness name, e.g. ``"claude-native"``.
:param fingerprint: The launch-config fingerprint (:func:`fingerprint_of`).
:returns: ``<data-dir>/cache/model-catalogs/<harness>-<fingerprint>.json``.
"""
return _data_dir() / "cache" / "model-catalogs" / f"{harness}-{fingerprint}.json"
def read_catalog(harness: str, fingerprint: str) -> list[dict[str, Any]] | None:
"""Read the stored catalog rows for one (harness, fingerprint).
:param harness: Canonical harness name.
:param fingerprint: The launch-config fingerprint.
:returns: The verbatim rows, or ``None`` on a miss / damaged file.
"""
path = catalog_path(harness, fingerprint)
try:
payload = json.loads(path.read_text())
except (OSError, ValueError):
return None
rows = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return None
return [row for row in rows if isinstance(row, dict) and row.get("id")]
def catalog_age_s(harness: str, fingerprint: str) -> float | None:
"""Age of the stored catalog in seconds, or ``None`` on a miss."""
path = catalog_path(harness, fingerprint)
try:
return max(0.0, time.time() - path.stat().st_mtime)
except OSError:
return None
def write_catalog(harness: str, fingerprint: str, rows: list[dict[str, Any]]) -> None:
"""Persist catalog rows atomically (best-effort; failures only log).
:param harness: Canonical harness name.
:param fingerprint: The launch-config fingerprint.
:param rows: Verbatim harness rows to persist.
"""
path = catalog_path(harness, fingerprint)
payload = {
"harness": harness,
"fingerprint": fingerprint,
"written_at": time.time(),
"models": rows,
}
try:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(handle, "w") as tmp:
json.dump(payload, tmp, separators=(",", ":"))
os.replace(tmp_name, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
except OSError:
_logger.warning("could not persist the %s model catalog", harness, exc_info=True)
#: In-flight probes, keyed (harness, fingerprint) — the thin single-flight
#: wrapper the design keeps process-side: concurrent misses join one probe
#: instead of each spawning CLI processes.
_inflight: dict[tuple[str, str], asyncio.Task[list[dict[str, Any]] | None]] = {}
async def ensure_catalog(
harness: str,
fingerprint: str,
resolve: Callable[[], Awaitable[list[dict[str, Any]] | None]],
) -> list[dict[str, Any]] | None:
"""Store-first catalog access with a single probe in flight per key.
A hit serves immediately; a miss runs *resolve* once (concurrent
callers join it), persists a non-empty answer, and returns it.
:param harness: Canonical harness name.
:param fingerprint: The launch-config fingerprint.
:param resolve: Probe coroutine factory producing verbatim rows.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
cached = read_catalog(harness, fingerprint)
if cached is not None:
return cached
key = (harness, fingerprint)
task = _inflight.get(key)
if task is None or task.done():
async def _run() -> list[dict[str, Any]] | None:
try:
rows = await resolve()
finally:
_inflight.pop(key, None)
if rows:
write_catalog(harness, fingerprint, rows)
return rows
task = asyncio.create_task(_run(), name=f"model-catalog-{harness}")
_inflight[key] = task
return await asyncio.shield(task)
def default_row(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the catalog's single ``isDefault`` row, if any.
:param rows: Catalog rows.
:returns: The default row, or ``None``.
"""
return next((row for row in rows if row.get("isDefault") is True), None)
def catalog_contains(rows: list[dict[str, Any]], token: str) -> bool:
"""Whether *token* names a catalog row (by ``id`` or wire ``model``).
:param rows: Catalog rows.
:param token: A picker row id or wire model id.
:returns: ``True`` when some row's ``id`` or ``model`` equals *token*.
"""
return any(row.get("id") == token or row.get("model") == token for row in rows)
__all__ = [
"CATALOG_STALE_AFTER_S",
"catalog_age_s",
"catalog_contains",
"catalog_path",
"default_row",
"ensure_catalog",
"fingerprint_of",
"read_catalog",
"write_catalog",
]
+37 -43
View File
@@ -1,10 +1,16 @@
"""Owned static model fallbacks for CLI surfaces without discovery."""
"""Owned static model tables for Smart Routing.
Pre-launch picker listings carry no static stand-ins anymore the live
harness probes (see ``omnigent.host.connect``) are their source of truth.
What remains here is the router's operational data: rankings, arm menus,
and probed exclusions that no discovery API can provide.
"""
from __future__ import annotations
from dataclasses import dataclass
from omnigent.onboarding.provider_config import CLI_CONFIG_KIND, SUBSCRIPTION_KIND
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
@dataclass(frozen=True)
@@ -17,56 +23,44 @@ class StaticModelFallback:
discovery_gap: str
_CLAUDE_SUBSCRIPTION_MODELS = (
"claude-fable-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-haiku-4-5",
#: Curated preference ORDER for codex's current arms — a ranking hint only
#: (preferred first), consumed by the Databricks live-discovery ranker to
#: sort servable ids. It never invents picker rows: ids absent from the live
#: listing are simply not ranked by it.
_CODEX_ARM_PREFERENCE = StaticModelFallback(
model_ids=("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5"),
owner="Databricks model discovery (omnigent.databricks_model_discovery)",
provenance="Omnigent's release-curated Codex arm ordering",
discovery_gap="a workspace listing ranks models by neither recency nor capability",
)
#: Codex's own model slugs, which spell the version with a DOT
#: (``gpt-5.6-sol``). These reach codex's ChatGPT-account backend directly, so
#: the Databricks serving spelling (``databricks-gpt-5-6-sol``, hyphens only)
#: is rejected here with a 400 — unlike the gateway catalogs below, which are
#: correctly hyphenated. Ordered cheapest-safe default first.
_CODEX_MODELS = ("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5")
_STATIC_MODEL_FALLBACKS = {
(SUBSCRIPTION_KIND, "claude"): StaticModelFallback(
model_ids=_CLAUDE_SUBSCRIPTION_MODELS,
owner="Claude subscription adapter",
provenance="Omnigent's release-curated Claude Code alias catalog",
discovery_gap="Claude subscription logins expose no model-listing API",
),
(SUBSCRIPTION_KIND, "codex"): StaticModelFallback(
model_ids=_CODEX_MODELS,
owner="Codex subscription adapter",
provenance="Omnigent's release-curated Codex alias catalog",
discovery_gap="Codex subscription availability is not exposed before launch",
),
(CLI_CONFIG_KIND, "codex"): StaticModelFallback(
model_ids=_CODEX_MODELS,
owner="Codex CLI-config adapter",
provenance="Omnigent's release-curated Codex alias catalog",
discovery_gap=(
"Custom model_provider entries live in Codex config.toml and cannot "
"be enumerated on this catalog path"
),
),
_STATIC_MODEL_FALLBACKS: dict[tuple[str, str], StaticModelFallback] = {
(SUBSCRIPTION_KIND, "codex"): _CODEX_ARM_PREFERENCE,
}
def static_model_fallback(provider_kind: str, cli: str) -> StaticModelFallback | None:
"""Return the owned fallback for a provider kind and CLI, if registered."""
"""Return the owned fallback table for a provider kind and CLI, if registered."""
return _STATIC_MODEL_FALLBACKS.get((provider_kind, cli))
#: Codex's launch default when nothing else names a model. The bundled OpenAI
#: catalog's newest row is a bare family alias (``gpt-5.6``) that codex rejects,
#: so a codex launch defaults to a concrete variant from its own catalog.
CODEX_DEFAULT_MODEL = _STATIC_MODEL_FALLBACKS[(SUBSCRIPTION_KIND, "codex")].model_ids[0]
#: Codex's launch default when nothing else names a model. The bundled
#: OpenAI catalog's newest row is a bare family alias (``gpt-5.6``) that
#: codex rejects, so a codex launch defaults to a concrete variant from
#: codex's own catalog — dotted spelling, since the Databricks hyphenated
#: form 400s against codex's own backend.
_CODEX_LAUNCH_DEFAULT = StaticModelFallback(
model_ids=("gpt-5.6-sol",),
owner="Codex native launch (omnigent.inner.codex_executor)",
provenance="codex's own catalog slug for the cheapest current arm",
discovery_gap=(
"the launch default is resolved before any app-server probe can "
"answer, and codex rejects the bundled catalog's newest row (a bare "
"family alias)"
),
)
CODEX_DEFAULT_MODEL = _CODEX_LAUNCH_DEFAULT.model_ids[0]
# ── Smart Routing ───────────────────────────────────────────────────────────
+23 -3
View File
@@ -16,7 +16,7 @@ creates the Job — an init container prepares the workspace (``mkdir`` + option
which dials back over the existing managed launch-token tunnel. Because the host
is never started by ``exec``-ing into an already-running container, this launcher
needs no ``pods/exec`` rights and no exec transport it implements only
``prepare`` / ``provision`` / ``start_host`` / ``terminate``.
``prepare`` / ``provision`` / ``start_host`` / ``resume`` / ``terminate``.
Platform notes that shape this launcher:
@@ -982,7 +982,9 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
Server-managed only and entrypoint-as-host: :meth:`provision` reserves a Job
name, :meth:`start_host` creates a per-Job token Secret and a Job whose Pod
template's init container prepares the workspace and whose main container runs
``omnigent host``, and :meth:`terminate` deletes both. The Job uses
``omnigent host``. :meth:`resume` removes a dormant Job and its stale token
Secret so the managed-host wake path can recreate both under the same sandbox
id, while :meth:`terminate` permanently deletes them. The Job uses
``restartPolicy: OnFailure`` so the kubelet automatically restarts a crashed
host container, providing automatic failover within the Job's
``backoffLimit``. All transport rides the official ``kubernetes`` client's
@@ -992,6 +994,7 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
"""
provider: ClassVar[str] = "kubernetes"
can_resume: ClassVar[bool] = True
@property
def capabilities(self) -> SandboxCapabilities:
@@ -999,7 +1002,7 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
cli_bootstrap=False,
managed_launch=True,
local_port_forward=False,
resume_stopped=False,
resume_stopped=True,
programmatic_terminate=True,
classifies_runner_by_agent=True,
)
@@ -1739,6 +1742,23 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
if first_error is not None:
raise first_error
def resume(self, sandbox_id: str) -> None:
"""
Prepare a dormant Kubernetes sandbox for recreation in place.
Kubernetes Jobs cannot be restarted after their host process exits.
Remove the old Job and launch-token Secret so the shared managed-host
wake path can call :meth:`start_host` with the same sandbox id and a
freshly armed token. Operator-managed PVCs are external resources and
are not touched.
:param sandbox_id: The dormant Job name to recreate.
:raises click.ClickException: On an API delete failure other than
not-found.
"""
click.echo(f"▸ Resuming Kubernetes sandbox '{sandbox_id}'")
self.terminate(sandbox_id)
def _delete_with_retry(self, kind: str, name: str, delete: Callable[[], object]) -> None:
"""
Run *delete* with bounded retries on a transient timeout/connection
+7 -3
View File
@@ -295,8 +295,11 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
"Creating an OpenCode session requires a session bundle."
)
_update_startup_progress(startup_progress, "Creating OpenCode session...")
session_id = await _create_opencode_session(
client, session_bundle, terminal_launch_args=persist_args or None
session_id, _ = await asyncio.gather(
_create_opencode_session(
client, session_bundle, terminal_launch_args=persist_args or None
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading OpenCode session...")
@@ -337,7 +340,8 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -377,10 +377,13 @@ async def _prepare_pi_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Pi session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Pi session...")
session_id = await _create_pi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_pi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Pi session...")
@@ -421,7 +424,8 @@ async def _prepare_pi_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -324,10 +324,13 @@ async def _prepare_qwen_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a qwen session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating qwen session...")
session_id = await _create_qwen_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_qwen_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading qwen session...")
@@ -370,7 +373,8 @@ async def _prepare_qwen_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+19 -11
View File
@@ -8,23 +8,29 @@ from types import MappingProxyType
from omnigent.llms.errors import PermanentLLMError
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max"})
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"})
EFFORT_CLEAR_VALUES = frozenset({"default", "off", "reset"})
# Deprecated / vendor-written effort values mapped to the canonical value to
# use instead. The ChatGPT desktop app writes ``model_reasoning_effort =
# "ultra"`` into ``~/.codex/config.toml``, and the codex CLI forwards it as
# the retired ``max`` wire value — the OpenAI Responses API accepts neither
# (its ladder tops out at ``xhigh``). ``validate_effort`` coerces an alias
# only when the raw value is unsupported but the canonical value IS
# supported, so providers that genuinely support ``max`` (Anthropic) keep it
# unchanged.
# Fold a value to a canonical one, but only where the target ladder lacks it:
# ``validate_effort`` applies an alias only when the raw value is unsupported
# but the canonical one is. On the SDK/Responses codex ladder (``CODEX_EFFORTS``,
# capped at ``xhigh``) the ChatGPT app's ``ultra`` / retired ``max`` fold to
# ``xhigh``; ladders that carry them (codex-native ``CODEX_NATIVE_EFFORTS``,
# Anthropic's ``max``) keep them unchanged.
EFFORT_ALIASES: dict[str, str] = {"ultra": "xhigh", "max": "xhigh"}
OPENAI_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
ANTHROPIC_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
CLAUDE_EFFORTS = ANTHROPIC_EFFORTS
CODEX_EFFORTS = OPENAI_EFFORTS
# Codex-native drives the real codex process, which is the per-model authority
# on reasoning levels — it advertises them via ``model/list`` and validates the
# pairing itself. Sol reaches ``ultra``; the picker already gates which levels a
# model offers, so accept codex's full ladder here rather than re-clamping a
# valid pick down to ``xhigh``.
CODEX_NATIVE_EFFORTS = frozenset(
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
)
OPENAI_AGENTS_EFFORTS = OPENAI_EFFORTS
GEMINI_EFFORTS = frozenset({"low", "medium", "high"})
ANTIGRAVITY_EFFORTS = GEMINI_EFFORTS
@@ -36,7 +42,7 @@ COPILOT_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
def format_supported(values: Iterable[str]) -> str:
"""Return a stable comma-separated supported-values string."""
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
values_set = set(values)
return ", ".join(value for value in order if value in values_set)
@@ -61,8 +67,10 @@ def unsupported_effort_message(effort: str, provider: str, supported: Iterable[s
# above stay frozen: they are the wire APIs' own vocabularies.
_MODEL_EFFORT_FALLBACK: Mapping[str, str] = MappingProxyType({"glm-5-2": "medium"})
# Efforts a fallback model cannot accept, so a pinned high value coerces down.
# GLM tops out at ``high``, so every rung above it (``xhigh``/``max``/``ultra``)
# is unsupported.
_MODEL_EFFORT_UNSUPPORTED: Mapping[str, frozenset[str]] = MappingProxyType(
{"glm-5-2": frozenset({"xhigh", "max"})}
{"glm-5-2": frozenset({"xhigh", "max", "ultra"})}
)
+3 -2
View File
@@ -4814,7 +4814,7 @@ async def _cmd_theme(
host.output(_build_preview(selected.name))
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
_EFFORT_CLEAR_ALIASES = {"default", "off", "reset"}
@@ -4880,7 +4880,8 @@ async def _cmd_effort(
host.output(
Text.from_markup(
" [bold red]Invalid effort: "
f"{value} · expected none, minimal, low, medium, high, xhigh, max, or default[/]"
f"{value} · expected none, minimal, low, medium, high, "
"xhigh, max, ultra, or default[/]"
)
)
return
+14 -11
View File
@@ -221,17 +221,20 @@ def _dispatch_by_runtime(
"""
from omnigent.db.db_models import InvalidUuidError, uuid_to_bytes
# Resolve the id the argument contains, then canonicalize to bare hex
# before any lookup: a paste drags punctuation along (trailing period,
# wrapping quotes or backticks), and none of it can ever be part of a
# valid id — so strip it and resume rather than erroring. A malformed
# id would otherwise surface as a raw StatementError traceback from
# the local store's Uuid16 bind, and downstream consumers key
# sessions on the bare spelling.
try:
target = uuid_to_bytes(target.strip(_PASTE_PUNCTUATION)).hex()
except InvalidUuidError as exc:
raise click.ClickException("Invalid session id.") from exc
# Paste punctuation (trailing period, wrapping quotes/backticks) is never
# part of an id, so strip it. Only the local path binds the id to the sqlite
# store's Uuid16 column, so it must be a real uuid — reject a malformed one
# loudly rather than surfacing a raw StatementError. The remote server owns
# its id space (a managed deployment keys sessions on non-uuid ids) and
# validates the id itself, so forward it untouched, like the runner and SDK.
stripped = target.strip(_PASTE_PUNCTUATION)
if server is None:
try:
target = uuid_to_bytes(stripped).hex()
except InvalidUuidError as exc:
raise click.ClickException("Invalid session id.") from exc
else:
target = stripped
if server is not None:
wrapper = _read_wrapper_label_remote(server=server, conv_id=target)
+25 -2
View File
@@ -613,11 +613,34 @@ def _make_auth_token_factory(
"""
# Check stored OIDC token first.
if resolved_server_url:
from omnigent.cli_auth import load_token
from omnigent.cli_auth import (
REFRESH_MIN_REMAINING_SECONDS,
load_token,
refresh_stored_token,
)
oidc_token = load_token(resolved_server_url)
# Require enough remaining life that the token cannot lapse
# mid-handshake; a token inside that window falls through to
# the renewal path below rather than being used and rejected.
oidc_token = load_token(
resolved_server_url,
min_remaining_seconds=REFRESH_MIN_REMAINING_SECONDS,
)
if oidc_token:
return oidc_token
# Expired or near-lapse: renew from the login-issued refresh
# grant when one exists. This is what keeps an unattended host
# alive past session-JWT expiry — the tunnel rebuilds headers
# through this factory on every reconnect.
refreshed = refresh_stored_token(resolved_server_url)
if refreshed:
return refreshed
# Nothing to renew with: a near-expiry token that has NOT
# actually lapsed still authenticates, so prefer it over
# falling through to no credential at all.
still_valid = load_token(resolved_server_url)
if still_valid:
return still_valid
return _sdk_token()
# Probe once to check if a user credential is available.
+365 -28
View File
@@ -170,6 +170,25 @@ from omnigent.tools.builtins.load_skill import (
_logger = logging.getLogger(__name__)
# Claude-native session model listing: how long one request waits inline for
# the probe before answering 503-pending, and how long the probe may stay
# pending before the configured rows are served instead. Module-level so
# tests can patch the pacing.
_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S = 2.5
# Claude-native model switch confirmation: how long to watch the pane's
# statusLine snapshot for the switched model after typing ``/model``, and how
# often to re-read it. Module-level so tests can patch the pacing.
_CLAUDE_MODEL_CONFIRM_TIMEOUT_S = 10.0
_CLAUDE_MODEL_CONFIRM_POLL_S = 0.25
# How long the detached watcher keeps answering a /model confirm dialog that
# pops after the active turn settles (a mid-turn switch queues in the
# composer), and how often it looks. Long turns are common; the watch is
# cheap (one tmux capture per poll) and never types blind.
_CLAUDE_MODEL_LATE_DIALOG_BUDGET_S = 1200.0
_CLAUDE_MODEL_LATE_DIALOG_POLL_S = 2.0
def _warn_unresolved_sub_agent(session_id: str | None, sub_agent_name: str) -> None:
"""
@@ -2070,6 +2089,10 @@ def create_runner_app(
_active_turns: dict[str, asyncio.Task[None] | None] = {}
app.state.active_turns = _active_turns
_native_pane_status: dict[str, str] = {}
app.state.native_pane_status = _native_pane_status
# Detached watchers answering a /model confirm dialog that pops after
# the active turn settles (a mid-turn switch queues in the composer).
_model_dialog_watchers: set[asyncio.Task[None]] = set()
_session_message_buffers: dict[str, list[dict[str, Any]]] = {}
app.state.session_message_buffers = _session_message_buffers
_author_attribution_sessions: set[str] = set()
@@ -2092,6 +2115,7 @@ def create_runner_app(
app.state.desync_terminalized = _desync_terminalized
_background_tasks: set[asyncio.Task[Any]] = set()
_subagent_wake_pending: set[str] = set()
_last_rewake_notice: dict[str, str] = {}
_session_histories = _session_histories_ref
_last_server_item_id: dict[str, str] = {}
@@ -2273,6 +2297,8 @@ def create_runner_app(
app.state.session_resource_registry = resource_registry
def _publish_terminal_activity(session_id: str, terminal_id: str) -> None:
if process_manager is not None:
process_manager.note_activity(session_id)
_publish_event(
session_id,
{
@@ -3501,6 +3527,7 @@ def create_runner_app(
_session_event_queues.pop(session_id, None)
_session_inboxes.pop(session_id, None)
_subagent_wake_pending.discard(session_id)
_last_rewake_notice.pop(session_id, None)
_session_sub_agent_names.pop(session_id, None)
unregister_child_session(session_id)
unregister_subagent_work_for_session(session_id)
@@ -4075,7 +4102,16 @@ def create_runner_app(
return Response(status_code=204)
state = await _codex_native_bridge_state_for_session(conv_id, action="settings update")
if state is None:
return Response(status_code=204)
# No loaded Codex bridge means nothing applied the settings; a
# silent 204 here would let the caller claim a switch the
# app-server never saw.
return JSONResponse(
status_code=503,
content={
"error": "codex_native_settings_update_failed",
"detail": "Codex-native settings update requires a loaded Codex bridge.",
},
)
codex_client = client_for_transport(
state.socket_path,
@@ -4126,7 +4162,13 @@ def create_runner_app(
if resp.status_code == 200:
snapshot = resp.json()
if isinstance(snapshot, dict):
raw_model = snapshot.get("model_override") or snapshot.get("llm_model")
# ``llm_model`` is the harness's own report — the model
# the pane is actually on. ``model_override`` is only a
# request and may predate a relaunch or an unconfirmed
# switch, so it is the fallback, not the lead: a
# plan-mode toggle must re-assert the pane's real
# model, never resurrect a stale ask.
raw_model = snapshot.get("llm_model") or snapshot.get("model_override")
if isinstance(raw_model, str) and raw_model.strip():
model = raw_model.strip()
raw_effort = snapshot.get("reasoning_effort")
@@ -4188,7 +4230,9 @@ def create_runner_app(
from omnigent.codex_native_app_server import (
client_for_transport,
list_codex_model_options,
mark_launch_default,
)
from omnigent.codex_native_bridge import read_codex_home_config_model
state = await _codex_native_bridge_state_for_session(
conv_id,
@@ -4204,10 +4248,48 @@ def create_runner_app(
)
try:
await codex_client.connect()
return await list_codex_model_options(codex_client)
rows = await list_codex_model_options(codex_client)
finally:
with contextlib.suppress(Exception):
await codex_client.close()
active_model = await asyncio.to_thread(
read_codex_home_config_model,
Path(state.codex_home),
)
marked = mark_launch_default(rows, active_model)
# Write the live account rows back to the shared catalog store so the
# pre-launch picker converges to account truth after the first
# session — keeping the SHAPE's stored default (a session's own pin
# must not become the host-wide default).
asyncio.get_running_loop().create_task(
_write_back_codex_catalog([dict(row) for row in rows])
)
return marked
async def _write_back_codex_catalog(rows: list[_JsonObject]) -> None:
try:
from omnigent import model_catalog_store
from omnigent.codex_native_app_server import (
codex_catalog_fingerprint,
mark_launch_default,
resolve_native_codex_launch,
)
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
fingerprint = codex_catalog_fingerprint(launch)
stored = model_catalog_store.read_catalog("codex-native", fingerprint)
stored_default = next(
(row.get("id") for row in stored or [] if row.get("isDefault") is True),
None,
)
shaped = mark_launch_default(
rows, stored_default if isinstance(stored_default, str) else None
)
await asyncio.to_thread(
model_catalog_store.write_catalog, "codex-native", fingerprint, shaped
)
except Exception: # noqa: BLE001 — write-back is best-effort
_logger.debug("codex model-catalog write-back skipped", exc_info=True)
async def _handle_pi_native_model_change(
conv_id: str,
@@ -4267,6 +4349,50 @@ def create_runner_app(
publish_event=_publish_event,
)
async def _handle_claude_native_permission_mode_change(
conv_id: str,
mode: str | None,
) -> Response:
"""
Switch a live claude-native session's permission mode.
Claude Code can only set the mode at launch (``--permission-mode``)
or from its own shift+tab cycle, so the bridge drives that cycle
and verifies the pane landed on *mode*. A 200 carries the mode now
rendered, which the Omnigent server persists as the session's
current mode.
"""
from omnigent.claude_native_bridge import (
bridge_dir_for_bridge_id,
set_permission_mode,
)
if mode is None or not mode.strip():
return Response(status_code=204)
bridge_id = await _claude_native_bridge_id_for_session(
server_client=server_client,
session_id=conv_id,
)
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
try:
settled = await asyncio.to_thread(
set_permission_mode,
bridge_dir,
mode=mode.strip(),
timeout_s=1.0,
)
except (RuntimeError, ValueError) as exc:
return JSONResponse(
status_code=503,
content={
"error": "claude_native_permission_mode_failed",
"detail": _client_safe_error_detail(
exc, context="claude-native permission mode change"
),
},
)
return JSONResponse(status_code=200, content={"permission_mode": settled})
async def _handle_claude_native_effort_change(
conv_id: str,
effort: str | None,
@@ -4310,6 +4436,48 @@ def create_runner_app(
)
return Response(status_code=204)
async def _watch_late_model_dialog(
conv_id: str,
bridge_dir: Path,
expected: set[str],
) -> None:
"""Answer a ``/model`` confirm dialog that pops after the active turn.
A mid-turn switch queues in Claude's composer; the confirm dialog
renders only when the turn settles potentially minutes after the
injection's own watch and the request's confirm window. This watcher
presses Enter ONLY when the model dialog is verifiably on screen
(never blind), stops as soon as the statusLine reports one of the
expected spellings, and gives up quietly after its budget the
persisted request and the forwarder's verbatim report remain the
authoritative record either way.
"""
from omnigent.claude_native_bridge import (
SWITCH_MODEL_DIALOG_HINT,
confirm_dialog_if_open,
read_claude_status_model,
)
deadline = time.monotonic() + _CLAUDE_MODEL_LATE_DIALOG_BUDGET_S
while time.monotonic() < deadline:
try:
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
if current and current in expected:
return
await asyncio.to_thread(
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
)
except Exception: # noqa: BLE001 — best-effort; the report reconciles
_logger.debug(
"late model-dialog watch errored for session=%s", conv_id, exc_info=True
)
return
await asyncio.sleep(_CLAUDE_MODEL_LATE_DIALOG_POLL_S)
_logger.info(
"late model-dialog watch for session=%s ended without a confirmed switch",
conv_id,
)
async def _handle_claude_native_model_change(
conv_id: str,
model: str | None,
@@ -4321,7 +4489,9 @@ def create_runner_app(
from omnigent.claude_native_bridge import (
SWITCH_MODEL_DIALOG_HINT,
bridge_dir_for_bridge_id,
confirm_dialog_if_open,
inject_slash_command,
read_claude_status_model,
read_model_env,
)
@@ -4362,6 +4532,7 @@ def create_runner_app(
},
)
command = f"/model {model_arg}"
baseline = await asyncio.to_thread(read_claude_status_model, bridge_dir)
try:
# Accepted trade-off: ``/model <id>`` also saves the pick as the
# person's global default in ``~/.claude/settings.json``. Driving
@@ -4383,7 +4554,80 @@ def create_runner_app(
"detail": _client_safe_error_detail(exc, context="claude-native model change"),
},
)
return Response(status_code=204)
# Verify against the statusLine snapshot the forwarder already polls:
# Claude rewrites it on every render, including right after ``/model``.
# Expected spellings come from this session's own catalog rows (every
# row's ``model`` is the harness's own resolution), plus the typed arg
# and its selection mapping. Success replies only after the pane
# actually switched; the swallowed-dialog case answers non-2xx so the
# server surfaces it instead of the row silently claiming the pick.
expected = {value for value in (resolved_model, model_arg) if value}
for row in _claude_model_options_rows.get(conv_id) or []:
if row.get("id") in (selected_model, resolved_model) or row.get("model") in (
selected_model,
resolved_model,
):
row_model = row.get("model")
if isinstance(row_model, str) and row_model:
expected.add(row_model)
deadline = time.monotonic() + _CLAUDE_MODEL_CONFIRM_TIMEOUT_S
while True:
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
if current and (current in expected or (baseline and current != baseline)):
# The pane switched. When it landed somewhere other than the
# expected spelling, the forwarder's verbatim report is the
# truth the UI will settle on — the command still took effect.
return Response(status_code=204)
if baseline is None and current is None:
# No statusLine snapshot on either side of the injection: a
# live wrapper-managed pane writes one on every render, so
# this is a shape without the wrapper — the switch is
# unverifiable, not failed. Report success and leave the
# forwarder to reconcile the row.
_logger.warning(
"claude-native model change for session=%s could not be verified: "
"no statusLine snapshot in %s",
conv_id,
bridge_dir,
)
return Response(status_code=204)
# The confirm dialog can render well after the injection's own
# short watch (a warm repaint, or a queued command surfacing) —
# answer it whenever it shows inside the window.
await asyncio.to_thread(
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
)
if time.monotonic() >= deadline:
break
await asyncio.sleep(_CLAUDE_MODEL_CONFIRM_POLL_S)
if _native_pane_status.get(conv_id) in ("running", "waiting"):
# Mid-turn switch: Claude queues the typed command and applies it
# when the turn settles — its confirm dialog can pop minutes from
# now. Not a failure: answer success, keep a detached watcher on
# the late dialog, and let the forwarder's report settle the
# picker when the switch actually lands.
watcher = asyncio.create_task(
_watch_late_model_dialog(conv_id, bridge_dir, expected),
name=f"claude-model-dialog-{conv_id}",
)
_model_dialog_watchers.add(watcher)
watcher.add_done_callback(_model_dialog_watchers.discard)
_logger.info(
"claude-native model change for session=%s is queued behind an active "
"turn; watching for the late confirm dialog",
conv_id,
)
return Response(status_code=204)
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_unconfirmed",
"detail": (
f"the terminal did not confirm the switch to {model_arg} within "
f"{_CLAUDE_MODEL_CONFIRM_TIMEOUT_S:.0f}s — a dialog may be open in the pane"
),
},
)
async def _apply_claude_native_plan_verdict(
conv_id: str,
@@ -5386,12 +5630,20 @@ def create_runner_app(
_cond.notify_all()
async def _post_subagent_wake_notice(
parent_id: str, notice: str, child_id: str, created_by: str | None
parent_id: str,
notice: str,
child_id: str,
created_by: str | None,
*,
is_rewake: bool = False,
) -> None:
delivered = await _deliver_subagent_wake_post(
server_client, parent_id, notice, created_by=created_by
)
if not delivered:
if delivered:
if is_rewake:
_last_rewake_notice[parent_id] = notice
else:
_subagent_wake_pending.discard(parent_id)
_logger.warning(
"Sub-agent wake POST failed for parent=%s child=%s after %d attempt(s); "
@@ -5401,7 +5653,7 @@ def create_runner_app(
_WAKE_POST_MAX_ATTEMPTS,
)
def _schedule_subagent_wake(entry: _SubagentWorkEntry) -> None:
def _schedule_subagent_wake(entry: _SubagentWorkEntry, *, is_rewake: bool = False) -> None:
if entry.parent_session_id == entry.child_session_id:
return
inbox = _session_inboxes.get(entry.parent_session_id)
@@ -5413,30 +5665,39 @@ def create_runner_app(
loop = asyncio.get_running_loop()
except RuntimeError:
return
_subagent_wake_pending.add(entry.parent_session_id)
notice = _format_subagent_wake_notice(
agent=entry.agent,
title=entry.title,
status=entry.status,
pending=inbox.qsize(),
)
if is_rewake and notice == _last_rewake_notice.get(entry.parent_session_id):
return
_subagent_wake_pending.add(entry.parent_session_id)
_wake_task = loop.create_task(
_post_subagent_wake_notice(
entry.parent_session_id,
notice,
entry.child_session_id,
entry.created_by,
is_rewake=is_rewake,
)
)
_wake_task.add_done_callback(_background_tasks.discard)
_background_tasks.add(_wake_task)
def _rewake_parent_if_inbox_stranded(parent_session_id: str) -> None:
inbox = _session_inboxes.get(parent_session_id)
drained = inbox is None or inbox.empty()
if drained:
# A drained inbox ends the stranding episode, so the recorded
# re-wake no longer describes outstanding work; forget it or a
# later episode's matching notice is wrongly deduped.
_last_rewake_notice.pop(parent_session_id, None)
if parent_session_id not in _subagent_wake_pending:
return
_subagent_wake_pending.discard(parent_session_id)
inbox = _session_inboxes.get(parent_session_id)
if inbox is None or inbox.empty():
if drained:
return
entries = list_subagent_work(parent_session_id)
if not entries:
@@ -5445,7 +5706,7 @@ def create_runner_app(
entries,
key=lambda entry: entry.completed_at if entry.completed_at is not None else 0.0,
)
_schedule_subagent_wake(latest)
_schedule_subagent_wake(latest, is_rewake=True)
def _mark_subagent_terminal_and_wake(
child_session_id: str, *, status: str, output: str | None
@@ -6939,6 +7200,24 @@ def create_runner_app(
)
return Response(status_code=204)
if body_type == "permission_mode_change":
harness = _session_harness_name(conversation_id)
if harness == "claude-native":
mode = body.get("permission_mode") if isinstance(body, dict) else None
if mode is not None and not isinstance(mode, str):
return JSONResponse(
status_code=400,
content={
"error": "invalid_input",
"detail": "Body 'permission_mode' must be a string or null",
},
)
return await _handle_claude_native_permission_mode_change(
conversation_id,
mode,
)
return Response(status_code=204)
codex_goal_response = await codex_goal_runner.handle_event(
conversation_id,
body_type,
@@ -7824,18 +8103,23 @@ def create_runner_app(
override=transport,
spec_transport=entry.instance.terminal_transport,
)
bridge = (
bridge_tmux_control_to_websocket
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
else bridge_tmux_pty_to_websocket
)
await bridge(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
on_client_interaction=entry.instance.note_client_interaction,
)
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
await bridge_tmux_control_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
on_client_interaction=entry.instance.note_client_interaction,
)
else:
await bridge_tmux_pty_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
on_client_interaction=entry.instance.note_client_interaction,
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
)
# Reused by the loopback direct-attach listener (see
# ``omnigent.runner.direct_attach``): same attach handler served on a
@@ -8538,10 +8822,23 @@ def create_runner_app(
}
return JSONResponse(status_code=200, content={"models": models})
# Claude's session listing IS the shared launch catalog: the same
# fingerprint-keyed store file the launch resolved against and the
# host's pre-launch picker serves — identical by construction, no
# separate composition. Cached per session for its lifetime (the launch
# config cannot change under it). A cold store pays one probe: a short
# inline wait answers a warm one, past that the endpoint answers 503
# (the server's fetch retries those) while the store's single-flight
# probe completes in the background.
_claude_model_options_rows: dict[str, list[dict[str, object]]] = {}
@app.get("/v1/sessions/{session_id}/claude-model-options")
async def get_session_claude_model_options(session_id: str) -> JSONResponse:
if _session_harness_name(session_id) != "claude-native":
return JSONResponse(status_code=200, content={"models": []})
cached = _claude_model_options_rows.get(session_id)
if cached is not None:
return JSONResponse(status_code=200, content={"models": cached})
try:
claude_config = await _resolve_session_claude_launch_config(session_id)
except click.ClickException as exc:
@@ -8573,12 +8870,52 @@ def create_runner_app(
),
},
)
from omnigent.claude_native import claude_native_model_options
from omnigent.claude_native import claude_launch_catalog
return JSONResponse(
status_code=200,
content={"models": claude_native_model_options(claude_config)},
)
rows: list[dict[str, object]] | None
try:
# The store's single-flight probe survives this wait expiring
# (ensure_catalog shields it), so a 503 here is genuinely
# "pending", not "restarted".
async with asyncio.timeout(_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S):
rows = await claude_launch_catalog(claude_config)
except TimeoutError:
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_options_pending",
"detail": "the harness model probe is still resolving",
},
)
if not rows:
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_options_failed",
"detail": "the harness model probe failed; retrying",
},
)
_claude_model_options_rows[session_id] = rows
return JSONResponse(status_code=200, content={"models": rows})
@app.get("/v1/sessions/{session_id}/model-options")
async def get_session_model_options(session_id: str) -> JSONResponse:
"""One route for every harness family's session model listing.
The runner derives the harness from the session the four
harness-named routes above/below remain as compatibility aliases
for older servers (deprecated; remove in 0.11.0).
"""
harness = _session_harness_name(session_id)
if harness == "claude-native":
return await get_session_claude_model_options(session_id)
if harness in ("codex-native", "opencode-native"):
return await get_session_codex_model_options(session_id)
if harness == "cursor-native":
return await get_session_cursor_model_options(session_id)
if harness == "kiro-native":
return await get_session_kiro_model_options(session_id)
return JSONResponse(status_code=200, content={"models": []})
@app.post("/v1/sessions/{session_id}/skills/resolve")
async def resolve_session_skill(session_id: str, request: Request) -> JSONResponse:
+7 -1
View File
@@ -436,7 +436,11 @@ class NativeInterruptRunner:
return Response(status_code=204)
async def _claude_stop(self, conv_id: str) -> Response:
from omnigent.claude_native_bridge import bridge_dir_for_bridge_id, kill_session
from omnigent.claude_native_bridge import (
TmuxSessionNotAdvertised,
bridge_dir_for_bridge_id,
kill_session,
)
bridge_id = await _claude_native_bridge_id_for_session(
server_client=self._server_client,
@@ -445,6 +449,8 @@ class NativeInterruptRunner:
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
try:
await asyncio.to_thread(kill_session, bridge_dir, timeout_s=1.0)
except TmuxSessionNotAdvertised:
self._logger.debug("claude-native stop: no live tmux for %s", conv_id)
except RuntimeError as exc:
return JSONResponse(
status_code=503,
+90 -14
View File
@@ -3768,6 +3768,37 @@ async def _auto_create_codex_terminal(
from omnigent.inner.codex_executor import _find_codex_cli
_codex_cli_path = _find_codex_cli()
# Explicit launches (model-flows design §4): validate an explicit request
# against the shared catalog, and give a Default launch on codex's own
# login the ACCOUNT's real default — so the ``model =`` line copied from
# the user's shared config can never govern a session (the stale-gpt-5.4
# 400 class). Profile-backed shapes already resolve their default at
# materialization time and are left alone.
if launch_config.model_override or (
_codex_launch.model is None and _codex_launch.profile is None
):
from dataclasses import replace as _dataclass_replace
from omnigent.codex_native_app_server import codex_launch_catalog
from omnigent.model_catalog_store import catalog_contains, default_row
_codex_catalog = await codex_launch_catalog(codex_path=_codex_cli_path)
if launch_config.model_override and _codex_catalog:
if not catalog_contains(_codex_catalog, launch_config.model_override):
raise click.ClickException(
f"the requested model {launch_config.model_override!r} is not in "
"this host's current model list — it may have changed since the "
"pick. Pick again from the model menu."
)
if _codex_launch.model is None and _codex_launch.profile is None and _codex_catalog:
_catalog_default = default_row(_codex_catalog)
_default_id = (
str(_catalog_default.get("id") or _catalog_default.get("model") or "") or None
if _catalog_default is not None
else None
)
if _default_id:
_codex_launch = _dataclass_replace(_codex_launch, model=_default_id)
# Cancel any surviving forwarder first so its teardown closes the OLD app-server,
# not the one registered below — and so it can't mirror alongside the new one.
await _cancel_auto_forwarder_task(session_id)
@@ -4002,9 +4033,9 @@ async def _auto_create_codex_terminal(
ap_server_url=launch_config.policy_server_url,
ap_auth_headers=policy_headers,
bypass_sandbox=launch_config.bypass_sandbox,
# Codex 0.146 prompts for project trust before creating a thread.
# This TUI runs detached for the web UI, so trust the runner-selected
# workspace in the session-private config instead of blocking forever.
# Codex can show project-trust and legacy-model migration prompts before
# creating a thread. This TUI runs detached for the web UI, so persist
# the runner-owned acknowledgements in the private session config.
trust_project=True,
**routed_spawn_extras,
)
@@ -4129,18 +4160,14 @@ async def _auto_create_codex_terminal(
# hook sources itself; skip the interactive trust prompt
# that headless sub-agents can never answer.
#
# Requires a *positively parsed* version, unlike the
# hooks-file gate in ``codex_native_app_server``, which
# treats an unknown version as supported. The two differ
# because their failure modes do: an unsupported hooks
# file is ignored by codex and caught downstream at the
# trust check, whereas an unknown CLI flag aborts argv
# parsing — so a transient ``codex --version`` hiccup on a
# pre-0.131 codex would turn a recoverable trust prompt
# into a dead terminal.
# A failed version probe must not restore the interactive
# gate: Omnigent's supported Codex floor is newer than the
# release that added this flag. Otherwise a transient
# ``codex --version`` failure strands the queued web
# message behind the terminal-only review screen.
bypass_hook_trust=(
app_server.codex_cli_version is not None
and app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
app_server.codex_cli_version is None
or app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
),
),
env=codex_terminal_env(app_server),
@@ -6454,6 +6481,43 @@ async def _auto_create_claude_terminal(
or (claude_config.model if claude_config is not None else None),
claude_config,
)
# Explicit launches (model-flows design §4): consult the shared catalog
# only when it can change the outcome — to validate an explicit request,
# or to resolve a Default launch that would otherwise pass no ``--model``
# and leave the model to invisible CLI-private state.
if session_model_override or launch_model is None:
from omnigent.claude_native import claude_catalog_serves_model, claude_launch_catalog
from omnigent.model_catalog_store import default_row
launch_catalog: list[dict[str, object]] | None = None
try:
launch_catalog = await claude_launch_catalog(claude_config)
except Exception: # noqa: BLE001 — no catalog means no validation/default
_logger.warning(
"claude launch catalog unavailable for session=%s", session_id, exc_info=True
)
if session_model_override and launch_catalog:
resolved_request = (
resolve_claude_native_model_selection(session_model_override, claude_config)
or session_model_override
)
# A pane's ``/model`` persists the exact id it runs; the catalog
# may spell that model only by its family alias.
if not (
claude_catalog_serves_model(launch_catalog, session_model_override, claude_config)
or claude_catalog_serves_model(launch_catalog, resolved_request, claude_config)
):
raise click.ClickException(
f"the requested model {session_model_override!r} is not in this "
"host's current model list — it may have changed since the pick. "
"Pick again from the model menu."
)
if launch_model is None and launch_catalog:
catalog_default = default_row(launch_catalog)
if catalog_default is not None:
launch_model = (
str(catalog_default.get("model") or catalog_default.get("id") or "") or None
)
# Give an exact launch model (a Smart Routing pick is resolved before the
# terminal exists) a spelling of its own in the picker, so a later
# ``/model`` can return to it instead of stepping onto whatever the family
@@ -6465,6 +6529,18 @@ async def _auto_create_claude_terminal(
claude_config = claude_config_with_launch_model_pinned(claude_config, launch_model)
if record_launch_config is not None:
record_launch_config(session_id, claude_config)
# Persist the vocabulary + launch model onto the bridge so mid-session
# ``/model`` conversion reads THIS session's pins, not the runner's
# ambient env (the CLI path records these at prepare time; the runner
# resolves its config only after the bridge exists).
from omnigent.claude_native_bridge import record_model_vocabulary
await asyncio.to_thread(
record_model_vocabulary,
bridge_dir,
launch_env=claude_config.env if claude_config is not None else None,
launch_model=launch_model,
)
_logger.info(
"Claude terminal provider config resolved: session=%s configured=%s "
"env_keys=%s api_key_helper_set=%s model_set=%s launch_model=%s",
+470 -56
View File
@@ -20,6 +20,7 @@ Tool categories:
from __future__ import annotations
import asyncio
import base64
import dataclasses
import json
import logging
@@ -604,10 +605,240 @@ def build_native_relay_tool_schemas(spec: AgentSpec | None) -> list[_JsonObject]
# sys_os_write, e.g. following the ``build-omnigent`` skill).
_AGENT_CONFIG_SUBDIR = ".omnigent/agent-configs"
# Broad page size for the sys_agent_list fan-out reads. Orchestrators want
# the full launchable surface in one call, not a 20-row default page.
# Broad internal page size for discovery fan-out reads.
_AGENT_LIST_PAGE_LIMIT = 1000
_DISCOVERY_LIST_MAX_LIMIT = 100
# Match the existing default ceiling for ``sys_os_shell`` output. Discovery
# tools stay below the same Omnigent-owned budget instead of guessing a
# harness-specific limit.
_DISCOVERY_LIST_OUTPUT_MAX_CHARS = 100_000
_DISCOVERY_CURSOR_MAX_CHARS = 40_000
_DISCOVERY_START = "start"
_DISCOVERY_AT = "at"
_DISCOVERY_END = "end"
_DiscoveryState = tuple[str, str | None]
def _discovery_list_window(
args: _JsonObject,
tool_name: str,
page_sections: tuple[str, ...],
filters: _JsonObject,
) -> tuple[int | None, dict[str, _DiscoveryState], bool] | str:
"""Validate discovery pagination and decode its opaque continuation cursor."""
limit = args.get("limit")
if "limit" in args and (
not isinstance(limit, int)
or isinstance(limit, bool)
or not 1 <= limit <= _DISCOVERY_LIST_MAX_LIMIT
):
return json.dumps(
{
"error": (
f"{tool_name}: 'limit' must be an integer between 1 and "
f"{_DISCOVERY_LIST_MAX_LIMIT}"
)
}
)
cursor = args.get("cursor")
state: dict[str, _DiscoveryState] = dict.fromkeys(page_sections, (_DISCOVERY_START, None))
if cursor is None:
return cast(int | None, limit), state, False
if not isinstance(cursor, str) or not cursor:
return json.dumps({"error": f"{tool_name}: 'cursor' must be a non-empty string"})
if len(cursor) > _DISCOVERY_CURSOR_MAX_CHARS:
return json.dumps({"error": f"{tool_name}: pagination cursor is too long"})
try:
padding = "=" * (-len(cursor) % 4)
raw = base64.b64decode(cursor + padding, altchars=b"-_", validate=True)
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
if not isinstance(payload, dict) or set(payload) != {"v", "tool", "filters", "sections"}:
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
if payload.get("v") != 1:
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
if payload.get("tool") != tool_name:
return json.dumps({"error": f"{tool_name}: cursor was minted by a different tool"})
if payload.get("filters") != filters:
return json.dumps({"error": f"{tool_name}: cursor uses different filter arguments"})
sections = payload.get("sections")
if not isinstance(sections, dict) or set(sections) != set(page_sections):
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
for name in state:
value = sections.get(name)
if not isinstance(value, list) or len(value) != 2:
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
tag, position = value
if tag in {_DISCOVERY_START, _DISCOVERY_END}:
if position is not None:
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
elif tag == _DISCOVERY_AT:
if not isinstance(position, str) or not position:
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
else:
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
state[name] = (tag, cast(str | None, position))
return cast(int | None, limit), state, True
def _encode_discovery_cursor(
tool_name: str,
filters: _JsonObject,
state: dict[str, _DiscoveryState],
) -> str | None:
"""Serialize a discovery continuation cursor without exposing its shape."""
payload = json.dumps(
{
"v": 1,
"tool": tool_name,
"filters": filters,
"sections": {name: list(value) for name, value in state.items()},
},
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
cursor = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
return cursor if len(cursor) <= _DISCOVERY_CURSOR_MAX_CHARS else None
@dataclass(frozen=True)
class _DiscoveryPage:
"""Rows and continuation state from one discovery source."""
rows: list[_JsonObject]
has_more: bool
next_after: str | None = None
failed: bool = False
def _parse_discovery_page(body: object) -> _DiscoveryPage:
"""Validate the server envelope before trusting its continuation state."""
if not isinstance(body, dict):
return _DiscoveryPage([], False, failed=True)
data = body.get("data")
if not isinstance(data, list):
return _DiscoveryPage([], False, failed=True)
rows: list[_JsonObject] = []
for row in data:
if not isinstance(row, dict):
return _DiscoveryPage([], False, failed=True)
row_id = row.get("id")
if not isinstance(row_id, str) or not row_id:
return _DiscoveryPage([], False, failed=True)
rows.append(row)
has_more = body.get("has_more", False)
last_id = body.get("last_id")
if not isinstance(has_more, bool):
return _DiscoveryPage([], False, failed=True)
if last_id is not None and (not isinstance(last_id, str) or not last_id):
return _DiscoveryPage([], False, failed=True)
if has_more and last_id is None:
return _DiscoveryPage([], False, failed=True)
return _DiscoveryPage(rows, has_more, cast(str | None, last_id))
def _bounded_discovery_result(
sections: dict[str, list[_JsonObject]],
*,
limit: int | None,
cursor_state: dict[str, _DiscoveryState],
continued: bool,
source_pages: dict[str, _DiscoveryPage],
page_sections: tuple[str, ...] | None = None,
tool_name: str,
filters: _JsonObject,
) -> str:
"""Keep a small legacy result intact, otherwise return a fitting page."""
complete = json.dumps(sections)
if (
limit is None
and not continued
and not any(page.failed for page in source_pages.values())
and not any(page.has_more for page in source_pages.values())
and len(complete) <= _DISCOVERY_LIST_OUTPUT_MAX_CHARS
):
return complete
requested_limit = limit or _DISCOVERY_LIST_MAX_LIMIT
paged = page_sections or tuple(sections)
def _candidate(candidate_limit: int) -> str | None:
page = dict(sections)
has_more: dict[str, bool] = {}
next_state = dict(cursor_state)
for name in paged:
rows = sections[name]
page_rows = rows[:candidate_limit]
page[name] = page_rows
source_page = source_pages[name]
if source_page.failed:
# A failed read proves neither progress nor exhaustion. Keep
# the incoming position so the continuation retries it.
has_more[name] = True
continue
has_more[name] = len(rows) > candidate_limit or source_page.has_more
if not has_more[name]:
next_state[name] = (_DISCOVERY_END, None)
elif page_rows:
if name == "local_configs":
position = _optional_string(page_rows[-1].get("path"))
else:
identifier = "agent_id" if name == "builtins" else "session_id"
position = _optional_string(page_rows[-1].get(identifier))
if position is not None:
next_state[name] = (_DISCOVERY_AT, position)
elif source_page.has_more and source_page.next_after is not None:
next_state[name] = (_DISCOVERY_AT, source_page.next_after)
metadata: dict[str, object] = {
"limit": candidate_limit,
"has_more": has_more,
}
if any(has_more.values()):
cursor = _encode_discovery_cursor(tool_name, filters, next_state)
if cursor is None:
return None
metadata["next_cursor"] = cursor
return json.dumps({**page, "page": metadata})
# Cursor size depends on the last returned row, so serialized page size is
# not monotonic in the row limit. Check the bounded public range directly.
for candidate_limit in range(requested_limit, 0, -1):
candidate = _candidate(candidate_limit)
if candidate is not None and len(candidate) <= _DISCOVERY_LIST_OUTPUT_MAX_CHARS:
return candidate
empty_page = _candidate(0)
if empty_page is None:
return json.dumps(
{
"error": (
f"Discovery continuation exceeds the {_DISCOVERY_CURSOR_MAX_CHARS}-character "
"cursor limit."
)
}
)
if len(empty_page) > _DISCOVERY_LIST_OUTPUT_MAX_CHARS:
kind = "fixed_section"
oversized_sections = [
name for name, rows in sections.items() if name not in paged and rows
]
else:
kind = "paginated_row"
oversized_sections = [name for name in paged if sections[name]]
return json.dumps(
{
"error": (
f"Discovery result exceeds the {_DISCOVERY_LIST_OUTPUT_MAX_CHARS}-character "
"output limit even at the smallest page."
),
"page": {"has_more": {name: source_pages[name].has_more for name in paged}},
"oversized": {"kind": kind, "sections": oversized_sections},
}
)
# Union of all locally-dispatched tools.
_ALL_LOCAL_TOOLS = (
_OS_ENV_TOOLS
@@ -3738,7 +3969,24 @@ async def _execute_session_query_tool(
return json.dumps({"error": f"{tool_name}: malformed JSON arguments"})
if tool_name == "sys_session_list":
return await _session_list_via_rest(conversation_id, server_client, args.get("agent_name"))
agent_name = args.get("agent_name")
window = _discovery_list_window(
args,
tool_name,
("sessions",),
{"agent_name": agent_name if isinstance(agent_name, str) and agent_name else None},
)
if isinstance(window, str):
return window
limit, cursor_state, continued = window
return await _session_list_via_rest(
conversation_id,
server_client,
agent_name,
limit=limit,
cursor_state=cursor_state,
continued=continued,
)
if tool_name == "sys_session_get_history":
return await _session_get_history_via_rest(args, server_client)
if tool_name == "sys_session_get_info":
@@ -4051,11 +4299,23 @@ async def _execute_agent_tool(
if server_client is None:
return json.dumps({"error": f"{tool_name} requires server access"})
if tool_name == "sys_agent_list":
window = _discovery_list_window(
args,
tool_name,
("builtins", "session_agents", "local_configs"),
{},
)
if isinstance(window, str):
return window
limit, cursor_state, continued = window
return await _agent_list_via_rest(
server_client,
agent_spec=agent_spec,
conversation_id=conversation_id,
runner_workspace=runner_workspace,
limit=limit,
cursor_state=cursor_state,
continued=continued,
)
session_id = args.get("session_id")
if not isinstance(session_id, str) or not session_id:
@@ -4232,9 +4492,12 @@ async def _agent_download_via_rest(
async def _agent_list_fetch(
path: str,
server_client: httpx.AsyncClient,
) -> list[_JsonObject]:
*,
after: str | None,
limit: int,
) -> _DiscoveryPage:
"""
Fetch one page of a paginated list endpoint, returning its ``data``.
Fetch one cursor page of a paginated list endpoint.
Best-effort: returns ``[]`` on transport error or non-200 so a single
failing source degrades ``sys_agent_list`` to "that section is empty"
@@ -4243,21 +4506,24 @@ async def _agent_list_fetch(
:param path: The list endpoint path, e.g. ``"/v1/agents"`` or
``"/v1/sessions"``.
:param server_client: HTTP client pointed at the Omnigent server.
:returns: The ``data`` list from the paginated response (possibly
empty).
:param after: Server cursor from the previous page, if any.
:param limit: Maximum number of source rows to fetch.
:returns: Rows and server continuation metadata.
"""
try:
resp = await server_client.get(
path,
params={"limit": _AGENT_LIST_PAGE_LIMIT, "order": "desc"},
timeout=30.0,
)
params: dict[str, str | int] = {"limit": limit, "order": "desc"}
if after is not None:
params["after"] = after
resp = await server_client.get(path, params=params, timeout=30.0)
except Exception: # noqa: BLE001
return []
return _DiscoveryPage([], False, failed=True)
if resp.status_code != 200:
return []
body = _string_object_dict(resp.json())
return _json_object_list(body.get("data")) if body is not None else []
return _DiscoveryPage([], False, failed=True)
try:
body = resp.json()
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
return _DiscoveryPage([], False, failed=True)
return _parse_discovery_page(body)
def _scan_local_agent_configs(configs_dir: Path) -> list[_JsonObject]:
@@ -4419,6 +4685,9 @@ async def _agent_list_via_rest(
agent_spec: AgentSpec | None,
conversation_id: str | None,
runner_workspace: Path | None,
limit: int | None,
cursor_state: dict[str, _DiscoveryState],
continued: bool,
) -> str:
"""
List launchable agents across built-ins, session-bound, and local.
@@ -4448,19 +4717,70 @@ async def _agent_list_via_rest(
resolution of the local-config scan.
:param conversation_id: The caller's session id, for os_env cwd.
:param runner_workspace: The runner workspace, authoritative cwd.
:returns: JSON ``{builtins, session_agents, local_configs}``.
:param limit: Optional maximum rows returned from each source. When
omitted, the complete legacy result is preserved while it fits.
:param cursor_state: Opaque continuation positions for each source.
:returns: The legacy complete JSON result while it fits, otherwise a
bounded page with continuation metadata.
"""
builtins_raw = await _agent_list_fetch("/v1/agents", server_client)
sessions_raw = await _agent_list_fetch("/v1/sessions", server_client)
source_limit = limit or _AGENT_LIST_PAGE_LIMIT
builtins_page = (
_DiscoveryPage([], False)
if cursor_state["builtins"][0] == _DISCOVERY_END
else await _agent_list_fetch(
"/v1/agents",
server_client,
after=cursor_state["builtins"][1],
limit=source_limit,
)
)
sessions_page = (
_DiscoveryPage([], False)
if cursor_state["session_agents"][0] == _DISCOVERY_END
else await _agent_list_fetch(
"/v1/sessions",
server_client,
after=cursor_state["session_agents"][1],
limit=source_limit,
)
)
spec = _effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace)
assert spec.cwd is not None
configs_dir = Path(spec.cwd) / _AGENT_CONFIG_SUBDIR
local_configs = await asyncio.to_thread(_scan_local_agent_configs, configs_dir)
listing = _project_agent_list(builtins_raw, sessions_raw, local_configs)
local_state, local_after = cursor_state["local_configs"]
if local_state == _DISCOVERY_END:
remaining_configs = []
elif local_after is not None:
remaining_configs = [
row for row in local_configs if str(row.get("path", "")) > local_after
]
else:
remaining_configs = local_configs
listing = _project_agent_list(
builtins_page.rows,
sessions_page.rows,
remaining_configs[:source_limit],
)
listing["builtins"] = _in_spawn_family(
listing["builtins"], await _spawn_family(server_client, conversation_id)
)
return json.dumps(listing)
return _bounded_discovery_result(
listing,
limit=limit,
cursor_state=cursor_state,
continued=continued,
tool_name="sys_agent_list",
filters={},
source_pages={
"builtins": builtins_page,
"session_agents": sessions_page,
"local_configs": _DiscoveryPage(
listing["local_configs"],
len(listing["local_configs"]) < len(remaining_configs),
),
},
)
def _project_agent_list(
@@ -4511,6 +4831,10 @@ async def _session_list_via_rest(
conversation_id: str,
server_client: httpx.AsyncClient,
agent_name: object = None,
*,
limit: int | None,
cursor_state: dict[str, _DiscoveryState],
continued: bool,
) -> str:
"""
Return the two-view session list: ``sub_agents`` + global ``sessions``.
@@ -4528,11 +4852,33 @@ async def _session_list_via_rest(
:param server_client: HTTP client pointed at the Omnigent server.
:param agent_name: Optional agent-name filter for the global
``sessions`` view; ignored for ``sub_agents``.
:returns: JSON ``{"sub_agents": [...], "sessions": [...]}``.
:param limit: Optional maximum rows returned from the global sessions view. When
omitted, the complete legacy result is preserved while it fits.
:param cursor_state: Opaque continuation position for the global sessions view.
:returns: The legacy complete JSON result while it fits, otherwise a
bounded page with continuation metadata.
"""
sub_agents = await _collect_sub_agents(conversation_id, server_client)
sessions = await _collect_global_sessions(server_client, agent_name)
return json.dumps({"sub_agents": sub_agents, "sessions": sessions})
sessions_page = (
_DiscoveryPage([], False)
if cursor_state["sessions"][0] == _DISCOVERY_END
else await _collect_global_sessions(
server_client,
agent_name,
after=cursor_state["sessions"][1],
limit=limit or _AGENT_LIST_PAGE_LIMIT,
)
)
return _bounded_discovery_result(
{"sub_agents": cast(list[_JsonObject], sub_agents), "sessions": sessions_page.rows},
limit=limit,
cursor_state=cursor_state,
continued=continued,
tool_name="sys_session_list",
filters={"agent_name": agent_name if isinstance(agent_name, str) and agent_name else None},
source_pages={"sessions": sessions_page},
page_sections=("sessions",),
)
async def _rename_current_session_via_rest(
@@ -4668,7 +5014,10 @@ async def _resolve_runner_online_map(
async def _collect_global_sessions(
server_client: httpx.AsyncClient,
agent_name: object,
) -> list[_JsonObject]:
*,
after: str | None,
limit: int,
) -> _DiscoveryPage:
"""
Fetch the global session list via ``GET /v1/sessions``, with connectivity.
@@ -4683,38 +5032,50 @@ async def _collect_global_sessions(
:param server_client: HTTP client pointed at the Omnigent server.
:param agent_name: Optional agent-name filter; applied only when a
non-empty string.
:returns: The projected global session entries.
:param after: Server cursor from the previous page, if any.
:param limit: Maximum number of source rows to fetch.
:returns: Projected global session entries and continuation metadata.
"""
params: dict[str, str | int] = {"limit": _AGENT_LIST_PAGE_LIMIT, "order": "desc"}
params: dict[str, str | int] = {"limit": limit, "order": "desc"}
if isinstance(agent_name, str) and agent_name:
params["agent_name"] = agent_name
if after is not None:
params["after"] = after
try:
resp = await server_client.get("/v1/sessions", params=params, timeout=30.0)
except Exception: # noqa: BLE001
return []
return _DiscoveryPage([], False, failed=True)
if resp.status_code != 200:
return []
body = _string_object_dict(resp.json())
if body is None:
return []
rows = _json_object_list(body.get("data"))
return _DiscoveryPage([], False, failed=True)
try:
body = resp.json()
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
return _DiscoveryPage([], False, failed=True)
page = _parse_discovery_page(body)
if page.failed:
return page
rows = page.rows
online = await _resolve_runner_online_map(rows, server_client)
return [
{
"session_id": r.get("id"),
# Hide the internal ``-native-ui`` wrapper name (e.g.
# ``pi-native-ui`` -> ``Pi``) in the global listing too, matching
# ``sys_session_get_info``. The server-side ``agent_name`` filter
# above still receives the caller's raw argument unchanged.
"agent_name": public_agent_name(_optional_string(r.get("agent_name"))),
"title": r.get("title"),
"status": r.get("status"),
"runner_id": r.get("runner_id"),
"runner_online": online.get(_optional_string(r.get("runner_id")) or ""),
"parent_session_id": r.get("parent_session_id"),
}
for r in rows
]
return _DiscoveryPage(
[
{
"session_id": r.get("id"),
# Hide the internal ``-native-ui`` wrapper name (e.g.
# ``pi-native-ui`` -> ``Pi``) in the global listing too, matching
# ``sys_session_get_info``. The server-side ``agent_name`` filter
# above still receives the caller's raw argument unchanged.
"agent_name": public_agent_name(_optional_string(r.get("agent_name"))),
"title": r.get("title"),
"status": r.get("status"),
"runner_id": r.get("runner_id"),
"runner_online": online.get(_optional_string(r.get("runner_id")) or ""),
"parent_session_id": r.get("parent_session_id"),
}
for r in rows
],
page.has_more,
page.next_after,
)
def _child_rows_to_entries(
@@ -6998,6 +7359,53 @@ async def _execute_task_lifecycle_tool(
)
async def _post_session_stop(server_client: httpx.AsyncClient, task_id: str) -> str | None:
"""Hard-stop one claude-native child through the server."""
try:
resp = await server_client.post(
f"/v1/sessions/{task_id}/events",
json={"type": "stop_session", "data": {}},
timeout=30.0,
)
except httpx.HTTPError as exc:
return f"Error: sys_cancel_task stop_session failed: {type(exc).__name__}: {exc}"
if resp.status_code >= 400:
return (
f"Error: sys_cancel_task stop_session returned {resp.status_code}: {resp.text[:200]}"
)
return None
async def _cancel_evicted_claude_native_subagent(
task_id: str,
*,
conversation_id: str,
server_client: httpx.AsyncClient | None,
) -> str:
"""Stop an owned claude-native child after its work entry was evicted."""
if server_client is None:
return "Error: sys_cancel_task requires server access for sub-agent tasks"
try:
resp = await server_client.get(f"/v1/sessions/{task_id}", timeout=10.0)
except httpx.HTTPError as exc:
return f"Error: sys_cancel_task lookup failed: {type(exc).__name__}: {exc}"
if resp.status_code != 200:
return f"Error: no in-flight task with task_id {task_id}"
snapshot = resp.json()
labels = snapshot.get("labels") if isinstance(snapshot, dict) else None
if (
not isinstance(snapshot, dict)
or snapshot.get("parent_session_id") != conversation_id
or not isinstance(labels, dict)
or labels.get(_SESSION_WRAPPER_LABEL_KEY) != CLAUDE_NATIVE_WRAPPER_VALUE
):
return f"Error: no in-flight task with task_id {task_id}"
stop_error = await _post_session_stop(server_client, task_id)
if stop_error is not None:
return stop_error
return json.dumps({"cancelled": True, "task_id": task_id, "status": "cancelled"})
async def _cancel_subagent_task(
args: _JsonObject,
*,
@@ -7040,14 +7448,22 @@ async def _cancel_subagent_task(
if conversation_id is None:
return "Error: sys_cancel_task requires conversation_id"
entry = _runner_app.get_subagent_work(str(task_id))
if entry is None or entry.parent_session_id != conversation_id:
if entry is None:
return await _cancel_evicted_claude_native_subagent(
str(task_id),
conversation_id=conversation_id,
server_client=server_client,
)
if entry.parent_session_id != conversation_id:
return f"Error: no in-flight task with task_id {task_id}"
# A dispatched child sits in ``launching`` until its runtime emits a real
# busy edge (see ``mark_subagent_work_started``). Cancellation must still
# route to the child during that window — otherwise cancelling a slow-to-
# start sub-agent would silently no-op and leave it running. Only terminal
# states (``completed`` / ``failed`` / ``cancelled``) short-circuit here.
if entry.status not in ("launching", "running", "waiting"):
# start sub-agent would silently no-op and leave it running. A failed
# claude-native entry still falls through because its pane may be alive.
is_claude_native = entry.wrapper_label == CLAUDE_NATIVE_WRAPPER_VALUE
can_stop_failed_claude = is_claude_native and entry.status == "failed"
if entry.status not in ("launching", "running", "waiting") and not can_stop_failed_claude:
return json.dumps(
{
"cancelled": entry.status == "cancelled",
@@ -7060,9 +7476,7 @@ async def _cancel_subagent_task(
# claude-native is the only harness with a runner-side hard-stop; every
# other harness 204 no-ops on stop_session, so route them to interrupt.
event_type = (
"stop_session" if entry.wrapper_label == CLAUDE_NATIVE_WRAPPER_VALUE else "interrupt"
)
event_type = "stop_session" if is_claude_native else "interrupt"
try:
resp = await server_client.post(
@@ -53,6 +53,7 @@ from omnigent.runner.transports.ws_tunnel.limits import (
TUNNEL_KEEPALIVE_PING_INTERVAL_S,
TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
)
from omnigent.suspend_watch import watch_for_resume
from omnigent.tls import client_ssl_context
_logger = logging.getLogger(__name__)
@@ -354,6 +355,15 @@ async def serve_tunnel(
login_redirect_streak = 0
http_auth_rejection_streak = 0
# Set by the per-connection suspend watcher (in _serve_tunnel_once) when it
# aborts the live tunnel after a wake from system suspend. Read at the
# bottom of the loop to force a prompt reconnect (skip the backoff).
woke_from_suspend = False
def _note_resume_from_suspend() -> None:
nonlocal woke_from_suspend
woke_from_suspend = True
while True:
if shutdown_event is not None and shutdown_event.is_set():
# A shutdown requested between reconnect attempts (no live
@@ -380,6 +390,7 @@ async def serve_tunnel(
shutdown_event=shutdown_event,
on_graceful_shutdown=on_graceful_shutdown,
on_connected=_mark_connected,
on_resume_note=_note_resume_from_suspend,
direct_attach_port=direct_attach_port,
direct_attach_token=direct_attach_token,
**activity_kwargs,
@@ -491,6 +502,15 @@ async def serve_tunnel(
retry_reason = str(exc)
except (ConnectionError, OSError, ValueError) as exc:
retry_reason = str(exc)
if woke_from_suspend:
# A wake from system suspend already aborted the live tunnel (see
# _serve_tunnel_once's watcher). The abrupt close would otherwise
# ride the escalating backoff; force a prompt reconnect at the base
# delay, like a server recycle, so the session reattaches at once.
woke_from_suspend = False
delay_s = _INITIAL_RECONNECT_DELAY_S
recycle = True
retry_reason = "resumed from system suspend; reconnecting promptly"
jittered = delay_s * (
1.0 + random.uniform(-_RECONNECT_JITTER_FRACTION, _RECONNECT_JITTER_FRACTION)
)
@@ -652,6 +672,7 @@ async def _serve_tunnel_once(
shutdown_event: asyncio.Event | None = None,
on_graceful_shutdown: Callable[[], None] | None = None,
on_connected: Callable[[], None] | None = None,
on_resume_note: Callable[[], None] | None = None,
direct_attach_port: int | None = None,
direct_attach_token: str | None = None,
) -> None:
@@ -681,6 +702,10 @@ async def _serve_tunnel_once(
:param on_connected: Optional sync callback fired once the WS
upgrade is accepted. ``serve_tunnel`` uses it to distinguish a
runner that has authenticated from one that never has.
:param on_resume_note: Optional sync callback fired when a wake from
system suspend is detected on this connection (just before the dead
socket is aborted). ``serve_tunnel`` uses it to force a prompt
reconnect instead of the escalating backoff.
:returns: None.
"""
import websockets
@@ -742,6 +767,32 @@ async def _serve_tunnel_once(
direct_attach_token=direct_attach_token,
)
_logger.info("runner %s connected to %s", runner_id, tunnel_url)
def _on_resume_from_suspend(gap_s: float) -> None:
# Wake from system suspend: this socket is now half-open (the server
# already dropped it), so abort it to make the read below raise at
# once instead of waiting out the ~90s keepalive timeout;
# serve_tunnel then reconnects promptly. Skip during a graceful
# idle-reaper drain — aborting mid-drain would turn the clean
# end-of-stream close into an abrupt runner_disconnected.
if shutdown_event is not None and shutdown_event.is_set():
return
_logger.info(
"runner %s resumed from suspend (~%.0fs); dropping tunnel to reconnect",
runner_id,
gap_s,
)
if on_resume_note is not None:
on_resume_note()
transport = getattr(ws, "transport", None)
if transport is not None:
with contextlib.suppress(Exception):
transport.abort()
suspend_task = asyncio.create_task(
watch_for_resume(_on_resume_from_suspend),
name=f"runner-suspend-watch:{runner_id}",
)
try:
if shutdown_event is None:
async for raw in ws:
@@ -820,6 +871,9 @@ async def _serve_tunnel_once(
with contextlib.suppress(asyncio.CancelledError):
await shutdown_wait
finally:
suspend_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await suspend_task
await _cancel_dispatch_tasks(dispatch_tasks)
await _cancel_ws_channels(ws_channels)
@@ -16,7 +16,7 @@ import logging
import secrets
import uuid
from collections import deque
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import Any
from fastapi import Response
@@ -171,6 +171,15 @@ class ExecutorAdapter(HarnessApp):
executor._tool_executor = self._stable_tool_executor # type: ignore[attr-defined]
if getattr(executor, "_elicitation_handler", None) is None:
executor._elicitation_handler = self._stable_elicitation_handler # type: ignore[attr-defined]
# ACP-shaped executors also accept a choice bridge, to offer the agent's own
# permission scopes; the others don't define the attribute at all.
if (
hasattr(executor, "_elicitation_choice_handler")
and executor._elicitation_choice_handler is None # type: ignore[attr-defined]
):
executor._elicitation_choice_handler = ( # type: ignore[attr-defined]
self._stable_elicitation_choice_handler
)
if getattr(executor, "_policy_evaluator", None) is None:
executor._policy_evaluator = self._stable_policy_evaluator # type: ignore[attr-defined]
self._current_ctx = ctx
@@ -544,6 +553,25 @@ class ExecutorAdapter(HarnessApp):
return False
elicitation_id = f"elicit_{secrets.token_hex(16)}"
params = self._permission_card(tool_name, tool_input)
result = await ctx.elicit(elicitation_id, params)
if result.action == "decline":
# Signal via ctx.cancelled (the SDK swallows exceptions from control-request tasks).
ctx.cancelled.set()
return result.action == "accept"
def _permission_card(
self,
tool_name: str,
tool_input: dict[str, Any],
*,
requested_schema: dict[str, Any] | None = None,
) -> ElicitationRequestParams:
"""Build the approval-card params for a tool-permission elicitation.
With *requested_schema* the card renders one button per choice instead of
Approve/Reject; without it, the usual binary card.
"""
# Build a concise preview: truncate long args so the UI widget
# stays readable. 300 chars matches AP's policy-engine preview.
try:
@@ -553,21 +581,61 @@ class ExecutorAdapter(HarnessApp):
preview = preview[:300]
label = self._harness_label
policy_name = f"{label.lower()}_sdk_permission"
params = ElicitationRequestParams(
return ElicitationRequestParams(
mode="form",
message=f"{label} wants to use **{tool_name}**",
requestedSchema=None,
requestedSchema=requested_schema,
url=None,
phase="tool_call",
policy_name=policy_name,
policy_name=f"{label.lower()}_sdk_permission",
content_preview=f"{tool_name}({preview})",
)
async def _stable_elicitation_choice_handler(
self,
tool_name: str,
tool_input: dict[str, Any],
options: Sequence[str],
) -> str | None:
"""Stable bridge for a multiple-choice tool-permission elicitation.
Same gate as :meth:`_stable_elicitation_handler`, but the card offers the
harness's own permission scopes as buttons and the chosen label comes back,
so the user can grant the scope the agent already supports instead of
re-approving the same action every time.
:returns: The chosen label, or ``None`` when declined, cancelled, or the
reply carried no readable answer.
"""
ctx = self._current_ctx
if ctx is None:
# No active turn — decline by default, as the binary card does.
_logger.error(
"elicitation choice callback fired with no active turn context "
"(tool=%s); declining by default",
tool_name,
)
return None
elicitation_id = f"elicit_{secrets.token_hex(16)}"
# An ``answer`` enum is what the approval card renders as one button per
# option, and the reply names the chosen label in ``content["answer"]``.
params = self._permission_card(
tool_name,
tool_input,
requested_schema={
"type": "object",
"properties": {"answer": {"type": "string", "enum": list(options)}},
"required": ["answer"],
},
)
result = await ctx.elicit(elicitation_id, params)
if result.action == "decline":
# Signal via ctx.cancelled (the SDK swallows exceptions from control-request tasks).
ctx.cancelled.set()
return result.action == "accept"
if result.action != "accept":
if result.action == "decline":
ctx.cancelled.set()
return None
answer = (result.content or {}).get("answer")
return answer if isinstance(answer, str) else None
async def _stable_policy_evaluator(
self,
@@ -989,6 +989,20 @@ class HarnessProcessManager:
"""
return conversation_id in self._in_flight_response_ids
def note_activity(self, conversation_id: str) -> None:
"""Refresh the idle lease for an existing harness subprocess.
Native terminal turns do not pass through ``proxy_stream``, so their
terminal activity calls this method instead. No-op when the
conversation has no registered subprocess.
:param conversation_id: AP-allocated conversation id,
e.g. ``"conv_abc123"``.
"""
entry = self._entries.get(conversation_id)
if entry is not None:
entry.last_used_at = time.monotonic()
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
"""
Record that *conversation_id* has a live harness turn.
+29 -1
View File
@@ -1556,6 +1556,12 @@ def _build_acp_cli_spawn_env(
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_ACP_OS_ENV"] = os_env_payload
# Permission stance for approval cards. Absent leaves the harness wrap on its
# ``auto`` default (prompt); ``bypassPermissions`` skips the card for a call no
# policy had an opinion on, so a headless ACP worker doesn't park on a prompt.
permission_mode = spec.executor.config.get("permission_mode")
if permission_mode is not None:
env["HARNESS_ACP_PERMISSION_MODE"] = str(permission_mode)
return env
@@ -1668,6 +1674,12 @@ def _build_acp_spawn_env(
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_ACP_OS_ENV"] = os_env_payload
# Permission stance for approval cards. Absent leaves the harness wrap on its
# ``auto`` default (prompt); ``bypassPermissions`` skips the card for a call no
# policy had an opinion on, so a headless ACP worker doesn't park on a prompt.
permission_mode = spec.executor.config.get("permission_mode")
if permission_mode is not None:
env["HARNESS_ACP_PERMISSION_MODE"] = str(permission_mode)
return env
@@ -1735,6 +1747,18 @@ def _config_flag_is_true(value: object) -> bool:
return str(value).strip().lower() in {"1", "true", "yes"}
def _set_openai_agents_reasoning_item_id_policy_env(
env: dict[str, str],
value: object | None,
) -> None:
"""Validate and encode the OpenAI Agents SDK reasoning replay policy."""
if value is None:
return
if not isinstance(value, str) or value not in {"preserve", "omit"}:
raise ValueError("reasoning_item_id_policy must be 'preserve', 'omit', or unset")
env["HARNESS_OPENAI_AGENTS_REASONING_ITEM_ID_POLICY"] = value
def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
"""
Build the env-var dict the openai-agents harness wrap reads.
@@ -1742,7 +1766,7 @@ def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
Maps spec.executor fields the ``HARNESS_OPENAI_AGENTS_*``
env vars defined in
``omnigent/inner/openai_agents_sdk_harness.py``. Threads
model + auth + use_responses.
model + auth + Responses replay settings.
Auth resolution order (highest priority first):
@@ -1770,6 +1794,10 @@ def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_OPENAI_AGENTS_MODEL"] = model
_set_openai_agents_reasoning_item_id_policy_env(
env,
spec.executor.config.get("reasoning_item_id_policy"),
)
# ── Auth resolution ────────────────────────────────────────────────
# Priority: generic provider → spec.executor.auth → global config auth →
+38 -10
View File
@@ -1268,6 +1268,7 @@ def create_app(
agent_store=agent_store,
conversation_store=conversation_store,
permission_store=permission_store,
policy_store=policy_store,
host_store=host_store,
host_registry=host_registry,
agent_cache=agent_cache,
@@ -2720,6 +2721,22 @@ def create_app(
# auth routes and ``/v1/me`` share one roster. Consulted on each login
# to promote listed identities — the only admin path for OIDC, and an
# additive convenience for accounts.
# Login-issued refresh grants: both server-mintable providers
# (accounts, oidc) get a grant store so `omnigent login` can hand
# the CLI refresh material — without it, an unattended host dies
# permanently at session-JWT expiry (default 8 h). The store also
# backs the opt-in RFC 8628 device flow below.
device_grant_store = None
if (
isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source in ("accounts", "oidc")
and permission_store is not None
):
from omnigent.server.device_grant_store import DeviceGrantStore
device_grant_store = DeviceGrantStore(permission_store.storage_location)
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
if (
isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source == "accounts"
@@ -2731,7 +2748,10 @@ def create_app(
app.include_router(
create_accounts_auth_router(
auth_provider, account_store, admin_list, permission_store
auth_provider,
account_store,
admin_list,
permission_store,
),
prefix="/auth",
tags=["auth"],
@@ -2762,6 +2782,7 @@ def create_app(
admin_list,
oidc_account_store,
allowed_domains=frozenset(allowed_domains or ()) or None,
device_grant_store=device_grant_store,
),
prefix="/auth",
tags=["auth"],
@@ -2773,24 +2794,20 @@ def create_app(
)
# Device Authorization Grant (RFC 8628): opt-in, default-off via
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only. OIDC delegates
# login to the IdP (cli-ticket flow), so it neither needs nor mounts
# these routes. Wires the revocation lookup into the auth provider so
# revoking a grant immediately rejects its delegated access tokens.
# See designs/DEVICE_AUTH.md.
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only (the
# in-browser consent flow needs the accounts login page). Wires
# the full /oauth/* surface including the token endpoint. See
# designs/DEVICE_AUTH.md.
from omnigent.server.auth import env_var_is_truthy
if (
env_var_is_truthy("OMNIGENT_DEVICE_GRANT_ENABLED", default=False)
and isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source == "accounts"
and permission_store is not None
and device_grant_store is not None
):
from omnigent.server.device_grant_store import DeviceGrantStore
from omnigent.server.routes.device_auth import create_device_auth_router
device_grant_store = DeviceGrantStore(permission_store.storage_location)
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
app.include_router(
create_device_auth_router(auth_provider, device_grant_store),
tags=["oauth"],
@@ -2811,6 +2828,17 @@ def create_app(
"the server and its trusted client(s) to restrict initiation "
"to authorized clients. See designs/DEVICE_AUTH.md.",
)
elif isinstance(auth_provider, UnifiedAuthProvider) and device_grant_store is not None:
# No device flow, but login-issued refresh grants still need
# their token/revoke endpoints — in OIDC mode and in accounts
# mode without the flag alike.
from omnigent.server.routes.device_auth import create_oauth_token_router
app.include_router(
create_oauth_token_router(auth_provider, device_grant_store),
tags=["oauth"],
)
_logger.info("login-grant: /oauth/token + /oauth/revoke enabled")
# Mount the built web SPA at "/" if a build is present. The SPA is
# built into ``omnigent/server/static/web-ui/`` by ``web/``'s Vite
+15 -6
View File
@@ -53,6 +53,9 @@ _TRUTHY_STRINGS = ("1", "true", "yes")
# any path not covered here, so it can never touch admin / user-management
# endpoints (``/auth/users``, ``/auth/invite``, ``/auth/setup`` …) even if
# its underlying identity is an admin. Delegated clients only need these.
# First-party login-grant tokens carry no ``scope`` and are NOT restricted
# here — they renew the session JWT and keep its authority (see
# ``_check_cookie`` and ``routes/device_auth.LOGIN_GRANT_CLIENT_ID``).
_DELEGATED_ALLOWED_PREFIXES = (
"/health",
"/v1/agents",
@@ -601,18 +604,24 @@ class UnifiedAuthProvider(AuthProvider):
if not isinstance(user_id, str) or not user_id or user_id in _RESERVED_USERS:
return None
# Delegated (device-grant) tokens carry a ``grant_id`` claim.
# They get two extra, request-scoped checks — a fail-closed path
# allowlist and a live revocation lookup — so they are never
# served from the plain user-id cache (which would skip both).
# Grant-derived tokens carry a ``grant_id`` claim. They get
# request-scoped checks — a live revocation lookup, plus (for
# restricted tokens) a fail-closed path allowlist — so they are
# never served from the plain user-id cache (which would skip both).
grant_id = payload.get("grant_id")
if grant_id is not None:
if not isinstance(grant_id, str):
return None
if not delegated_path_allowed(request.url.path):
return None
if self._grant_revoked is not None and self._grant_revoked(grant_id):
return None
# The allowlist restricts DELEGATED tokens — a third-party
# client (e.g. Slack) acting on a user's behalf, marked by the
# ``scope`` claim. A first-party login grant carries no scope:
# its bearer is the user's own CLI/host, and the token renews
# the session JWT it replaced, so it keeps that same authority
# (still revocable via ``grant_id`` above).
if payload.get("scope") is not None and not delegated_path_allowed(request.url.path):
return None
return user_id
# Cache for remaining lifetime of the token.
+51
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import hashlib
import hmac
import secrets
from typing import cast
from sqlalchemy import and_, delete, or_, update
@@ -135,6 +136,56 @@ class DeviceGrantStore:
session.flush()
return _to_device_grant(row)
def create_redeemed_grant(
self,
grant_id: str,
*,
user_id: str,
client_id: str | None,
refresh_token_hash: str,
created_at: int,
) -> DeviceGrant:
"""Persist a grant born ``redeemed`` — no device-code consent step.
Backs login-issued refresh grants: the user just proved their
identity interactively (IdP browser flow or password prompt), so
the RFC 8628 pending approved dance would re-ask for consent
already given. The row starts ``redeemed`` with its refresh-token
digest set, exactly as if it had completed the device flow.
The device_code/user_code columns are filled with discarded
random material: no client ever polls with them, and ``pending``
purge conditions never match a ``redeemed`` row.
:param grant_id: Opaque grant id (public travels in JWTs).
:param user_id: The authenticated identity (the token ``sub``).
:param client_id: Public client name for audit, e.g.
``"omnigent-cli"``.
:param refresh_token_hash: HMAC digest of the initial refresh
token. The store never sees the raw token.
:param created_at: Unix epoch seconds; also ``approved_at``, the
anchor for the grant's absolute lifetime.
:returns: The created :class:`DeviceGrant`.
"""
with self._session("insert_redeemed_device_grant") as session:
row = SqlDeviceGrant(
id=grant_id,
device_code_hash=secrets.token_urlsafe(32),
user_code=secrets.token_urlsafe(16),
status=encode_device_grant_status("redeemed"),
client_id=client_id,
user_id=user_id,
refresh_token_hash=refresh_token_hash,
prev_refresh_token_hash=None,
created_at=created_at,
expires_at=created_at,
approved_at=created_at,
last_polled_at=None,
)
session.add(row)
session.flush()
return _to_device_grant(row)
def get_by_user_code(self, user_code: str) -> DeviceGrant | None:
"""Look up a grant by its short verification code.
+49 -42
View File
@@ -23,6 +23,7 @@ import dataclasses
from fastapi import Request
from omnigent.db.utils import shared_read_scope
from omnigent.entities import Conversation
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.server.auth import (
@@ -290,52 +291,58 @@ def _require_access_and_level_sync(
code=ErrorCode.UNAUTHORIZED,
)
# Single round-trip: admin flag + the user's and public grants on the
# conversation the caller asked about. The displayed level is the direct
# grant (no parent walk), matching get_permission_level exactly.
access = permission_store.resolve_access(user_id, conversation_id)
level = resolved_level(access)
# One read-only burst: the permission resolve, the conversation lookup,
# and any parent-chain walk all share a single pool checkout instead of
# one per store call. On the per-streamed-event path this is re-run for a
# session whose data is stable for the turn, so the checkout — plus
# ``pool_pre_ping`` — is the cost that matters.
with shared_read_scope():
# Single round-trip: admin flag + the user's and public grants on the
# conversation the caller asked about. The displayed level is the direct
# grant (no parent walk), matching get_permission_level exactly.
access = permission_store.resolve_access(user_id, conversation_id)
level = resolved_level(access)
# Admins bypass the conversation lookup entirely (mirrors
# check_session_access's admin short-circuit, which never reads the
# conversation). A missing conversation is left for the snapshot builder
# to 404 on, exactly as today.
if access.is_admin:
return SessionAccess(level=level, conversation=None)
# Admins bypass the conversation lookup entirely (mirrors
# check_session_access's admin short-circuit, which never reads the
# conversation). A missing conversation is left for the snapshot builder
# to 404 on, exactly as today.
if access.is_admin:
return SessionAccess(level=level, conversation=None)
conv = conversation_store.get_conversation(conversation_id)
if conv is None:
raise OmnigentError(
"Conversation not found",
code=ErrorCode.NOT_FOUND,
)
conv = conversation_store.get_conversation(conversation_id)
if conv is None:
raise OmnigentError(
"Conversation not found",
code=ErrorCode.NOT_FOUND,
)
if conv.parent_conversation_id is None:
# Top-level session: the access-governing grant lives on this same
# conversation, so reuse the rows already fetched — no extra reads.
allowed = resolved_allows(access, required_level)
else:
# Sub-agent: access delegates to the parent chain. Defer to the
# canonical recursive checker (its own reads); sub-agents are rare
# and the parent's grants are a different conversation's rows.
allowed = check_session_access(
user_id,
conv.parent_conversation_id,
required_level,
permission_store,
conversation_store,
)
if allowed:
return SessionAccess(level=level, conversation=conv)
if conv.parent_conversation_id is None:
# Top-level session: the access-governing grant lives on this same
# conversation, so reuse the rows already fetched — no extra reads.
allowed = resolved_allows(access, required_level)
else:
# Sub-agent: access delegates to the parent chain. Defer to the
# canonical recursive checker (its own reads); sub-agents are rare
# and the parent's grants are a different conversation's rows.
allowed = check_session_access(
user_id,
conv.parent_conversation_id,
required_level,
permission_store,
conversation_store,
)
if allowed:
return SessionAccess(level=level, conversation=conv)
# Denied — distinguish "has some access but not enough" (403) from
# "no access at all" (404, to avoid leaking session existence).
if conv.parent_conversation_id is None:
has_any = resolved_allows(access, 1)
else:
has_any = check_session_access(
user_id, conv.parent_conversation_id, 1, permission_store, conversation_store
)
# Denied — distinguish "has some access but not enough" (403) from
# "no access at all" (404, to avoid leaking session existence).
if conv.parent_conversation_id is None:
has_any = resolved_allows(access, 1)
else:
has_any = check_session_access(
user_id, conv.parent_conversation_id, 1, permission_store, conversation_store
)
if has_any:
level_name = _LEVEL_NAMES.get(required_level, str(required_level))
raise OmnigentError(
@@ -129,6 +129,12 @@ _EXTERNAL_SESSION_USAGE_TYPE: str = "external_session_usage"
_EXTERNAL_MODEL_CHANGE_TYPE: str = "external_model_change"
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE: str = "external_permission_mode_change"
_EXTERNAL_SESSION_TITLE_TYPE: str = "external_session_title"
_EXTERNAL_MODEL_OPTIONS_TYPE: str = "external_model_options"
@@ -186,6 +192,21 @@ _EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE: str = "external_codex_approval_mode_c
_CODEX_NATIVE_COLLABORATION_MODES: frozenset[str] = frozenset({"default", "plan"})
# Current permission mode of a live claude-native session.
# ``terminal_launch_args`` records only the launch mode, so this label is what
# the web UI reads back after a reload.
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY = "omnigent.claude_native.permission_mode"
# Permission modes switchable on a running session — the ones Claude
# Code's shift+tab cycle can reach. Mirrors
# ``claude_native_bridge.CYCLEABLE_PERMISSION_MODES``; ``dontAsk`` and
# ``bypassPermissions`` are launch-only and rejected on PATCH.
_CLAUDE_NATIVE_PERMISSION_MODES: frozenset[str] = frozenset(
{"default", "acceptEdits", "plan", "auto"}
)
_CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "Codex"
@@ -416,7 +437,9 @@ _ALLOWED_EVENT_TYPES: frozenset[str] = frozenset(ITEM_TYPE_TO_DATA_CLS.keys()) |
_EXTERNAL_MCP_STARTUP_TYPE,
_EXTERNAL_MODEL_CHANGE_TYPE,
_EXTERNAL_MODEL_OPTIONS_TYPE,
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_TITLE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
_EXTERNAL_SUBAGENT_START_TYPE,
_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
@@ -793,6 +816,8 @@ __all__ = [
"_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S",
"_CLAUDE_NATIVE_MODEL",
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
"_CLAUDE_NATIVE_PERMISSION_MODES",
"_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY",
"_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS",
"_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY",
"_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE",
@@ -838,11 +863,13 @@ __all__ = [
"_EXTERNAL_MODEL_OPTIONS_TYPE",
"_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE",
"_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE",
"_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE",
"_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE",
"_EXTERNAL_SESSION_INTERRUPTED_TYPE",
"_EXTERNAL_SESSION_STATUS_TYPE",
"_EXTERNAL_SESSION_STATUS_VALUES",
"_EXTERNAL_SESSION_SUPERSEDED_TYPE",
"_EXTERNAL_SESSION_TITLE_TYPE",
"_EXTERNAL_SESSION_TODOS_TYPE",
"_EXTERNAL_SESSION_USAGE_TYPE",
"_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT",
+237 -20
View File
@@ -136,6 +136,8 @@ from omnigent.server.routes._sessions.common import ( # noqa: F401
_CLAUDE_NATIVE_DESCRIPTION_LABEL_KEY,
_CLAUDE_NATIVE_EDIT_TOOLS,
_CLAUDE_NATIVE_HARNESS,
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
_CLAUDE_NATIVE_PERMISSION_MODES,
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
@@ -249,6 +251,7 @@ from omnigent.server.schemas import (
SessionMcpStartupEvent,
SessionModelEvent,
SessionModelOptionsEvent,
SessionPermissionModeEvent,
SessionReasoningEffortEvent,
SessionResourceListPage,
SessionResourcePaginatedList,
@@ -257,6 +260,7 @@ from omnigent.server.schemas import (
SessionStatusEvent,
SessionSupersededEvent,
SessionTerminalPendingEvent,
SessionTitleEvent,
SessionTodosEvent,
SkillSummary,
ToolOutputDeltaEvent,
@@ -310,6 +314,22 @@ def _publish_collaboration_mode(session_id: str, mode: str) -> None:
session_stream.publish(session_id, event.model_dump())
def _publish_permission_mode(session_id: str, mode: str) -> None:
"""
Publish the live claude-native permission mode for a session.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param mode: The active permission mode, e.g. ``"auto"``.
:returns: None.
"""
event = SessionPermissionModeEvent(
type="session.permission_mode",
conversation_id=session_id,
permission_mode=mode,
)
session_stream.publish(session_id, event.model_dump())
def _publish_policy_denied(session_id: str, reason: str, phase: str) -> None:
"""
Publish a native policy-DENY signal on the session stream.
@@ -2125,28 +2145,32 @@ async def _persist_external_model_change(
conversation_store: ConversationStore,
) -> None:
"""
Persist and broadcast a model switch made inside the terminal.
Persist and broadcast the model the harness reports it is running.
Mirrors a ``/model`` change typed into a claude-native session's
Claude Code pane (or picked via its in-TUI model picker) onto the
Omnigent session: writes ``model_override`` so the value survives reload
and publishes a ``session.model`` SSE event so the web picker
updates live. Unlike the PATCH path
(:func:`update_session`), this deliberately does NOT forward a
``model_change`` back to the runner the terminal is already on
the model, so re-injecting ``/model`` would loop.
Mirrors a harness-side model report the launch's own model, or a
``/model`` change made inside the pane onto the Omnigent session:
writes ``reported_model`` VERBATIM (the harness's own spelling,
never collapsed to a picker alias) so the value survives reload,
and publishes a ``session.model`` SSE event so every surface
re-renders from it. The user's request (``model_override``) is
deliberately untouched: requests and reports are separate roles,
and only reports are ever displayed. Unlike the PATCH path
(:func:`update_session`), this does NOT forward a ``model_change``
back to the runner the terminal is already on the model, so
re-injecting ``/model`` would loop.
No-ops (no write, no event) when the observed model already equals
the persisted ``model_override`` the common case on the webTUI
round-trip where the web PATCH set the override moments earlier.
No-ops (no write, no event) when the reported model already equals
the persisted ``reported_model`` the steady state between real
changes, since forwarders re-observe on every poll.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` (read at the route
boundary); ``conv.model_override`` is the dedupe baseline.
boundary); ``conv.reported_model`` is the dedupe baseline.
:param body: External model-change event body. ``data.model`` must
be a non-empty string tier alias, e.g. ``"opus"``.
:param conversation_store: Store used to upsert ``model_override``.
be a non-empty string the harness's verbatim model, e.g.
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"``.
:param conversation_store: Store used to upsert ``reported_model``.
:raises OmnigentError: If ``data.model`` is missing or not a
non-empty string.
"""
@@ -2157,12 +2181,12 @@ async def _persist_external_model_change(
code=ErrorCode.INVALID_INPUT,
)
model = raw_model.strip()
if conv.model_override == model:
if conv.reported_model == model:
return
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=model,
reported_model=model,
)
event = SessionModelEvent(
type="session.model",
@@ -2172,6 +2196,78 @@ async def _persist_external_model_change(
session_stream.publish(session_id, event.model_dump())
async def _persist_external_session_title(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> None:
"""
Persist and broadcast a session rename made inside the terminal.
Mirrors a ``/rename`` typed into a claude-native session's Claude Code
pane onto the Omnigent session: writes ``title`` so the new name
survives reload and publishes a ``session.title`` SSE event so the
web session list updates live.
The rename is authoritative an operator typing ``/rename`` is an
explicit act, so it overwrites whatever title the session currently
carries, including one set from the web UI. This is why it uses a
plain ``update_conversation`` rather than the seed-only
compare-and-swap behind ``POST /sessions/{id}/auto-title``, which
exists to stop an *automatic* titler from clobbering a human's name.
No-ops (no write, no event) when the title already matches, so a
forwarder that re-sends after a cursor rewind, or a rename echoing
back a name the web UI just set, costs nothing.
Declined for child sessions, whose titles are structural rather than
display text: ``sys_session_send`` writes them as ``"<agent>:<label>"``
and the sub-agent tooling parses them back apart. That also covers the
legacy ``:closed:`` title marker, which only ever lands on a child row
(both writers reject a non-sub-agent title).
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` (read at the route
boundary); ``conv.title`` is the dedupe baseline.
:param body: External title event body. ``data.title`` must be a
non-empty single-line string, e.g. ``"auth-refactor"``.
:param conversation_store: Store used to upsert ``title``.
:raises OmnigentError: If ``data.title`` is not a non-empty single line.
"""
raw_title = body.data.get("title")
# Newlines are rejected outright rather than folded into spaces — a
# multi-line title means the sender is confused, not that it wants one
# long line. Mirrors ``POST /sessions/{id}/auto-title``.
if not isinstance(raw_title, str) or "\n" in raw_title or "\r" in raw_title:
raise OmnigentError(
"external_session_title requires data.title to be a single-line string",
code=ErrorCode.INVALID_INPUT,
)
title = " ".join(raw_title.split())
if not title:
raise OmnigentError(
"external_session_title requires data.title to be non-empty",
code=ErrorCode.INVALID_INPUT,
)
if conv.parent_conversation_id is not None:
return
if conv.title == title:
return
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
title=title,
)
event = SessionTitleEvent(
type="session.title",
conversation_id=session_id,
title=title,
)
session_stream.publish(session_id, event.model_dump())
def _persist_external_model_options(
session_id: str,
conv: Conversation,
@@ -2352,6 +2448,51 @@ async def _persist_external_codex_collaboration_mode_change(
_publish_collaboration_mode(session_id, mode)
async def _persist_external_permission_mode_change(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> None:
"""
Persist a pane-observed claude-native permission mode as a session label.
The forwarder posts this when the pane's mode footer differs from what it
last reported i.e. the user pressed shift+tab in the TUI. Unlike the
PATCH path this needs no runner confirmation: the pane IS the source, so
the mode is already in effect.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` at the route boundary.
:param body: Event body; ``data.permission_mode`` must be a switchable mode.
:param conversation_store: Store used to upsert the mode label.
:returns: None.
:raises OmnigentError: If ``data.permission_mode`` is missing or unsupported.
"""
raw_mode = body.data.get("permission_mode")
if not isinstance(raw_mode, str) or not raw_mode.strip():
raise OmnigentError(
"external_permission_mode_change requires data.permission_mode "
"to be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
mode = raw_mode.strip()
if mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
raise OmnigentError(
"external_permission_mode_change requires data.permission_mode in "
f"{sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}; got {mode!r}",
code=ErrorCode.INVALID_INPUT,
)
if conv.labels.get(_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY) == mode:
return
await asyncio.to_thread(
conversation_store.set_labels,
session_id,
{_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY: mode},
)
_publish_permission_mode(session_id, mode)
async def _persist_external_codex_approval_mode_change(
session_id: str,
conv: Conversation,
@@ -3652,6 +3793,58 @@ def _require_collaboration_mode_forward(
)
def _require_permission_mode_forward(
session_id: str,
mode: str,
runner_result: _RunnerForwardResult | None,
) -> str:
"""
Fail when a live claude-native permission-mode switch wasn't applied.
The mode lives in the running TUI, so persisting the label without a
confirmed 2xx forward would let the UI claim auto mode while Claude still
prompts on every edit. Returns the mode the runner actually reached, so
the caller stores what the pane shows rather than what was asked for.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param mode: Requested permission mode, e.g. ``"auto"``.
:param runner_result: HTTP result returned by the runner, or ``None``
when no runner could be reached.
:returns: The mode the runner reports the pane is now in the
requested *mode* when the runner didn't echo one back.
:raises OmnigentError: If no runner was reachable or the runner could
not switch the session into *mode*.
"""
if runner_result is None:
raise OmnigentError(
f"Could not switch to {mode} mode: no live Claude runner is available "
f"for session {session_id!r}. Reconnect the session and try again.",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
if not 200 <= runner_result.status_code < 300:
# The runner's body carries why the cycle failed (e.g. the mode
# isn't in this session's cycle); surface it so the UI banner
# explains the failure instead of showing a bare status code.
detail = ""
try:
payload = json.loads(runner_result.body)
except (TypeError, ValueError):
payload = None
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
detail = f" {payload['detail']}"
raise OmnigentError(
f"Could not switch to {mode} mode for session {session_id!r}.{detail}",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
try:
body = json.loads(runner_result.body)
except (TypeError, ValueError):
return mode
settled = body.get("permission_mode") if isinstance(body, dict) else None
return settled if isinstance(settled, str) and settled else mode
def _publish_status(
session_id: str,
status: str,
@@ -5149,6 +5342,13 @@ def _surface_model_change_forward_failure(
if 200 <= runner_result.status_code < 300:
return
reason = f"the runner returned status {runner_result.status_code}"
# The runner's own detail names the concrete cause (e.g. "a dialog may
# be open in the pane"); carry it into the visible notice when present.
with contextlib.suppress(ValueError, TypeError):
parsed_body = json.loads(runner_result.body)
detail = parsed_body.get("detail") if isinstance(parsed_body, dict) else None
if isinstance(detail, str) and detail.strip():
reason = f"{reason} ({detail.strip()})"
_logger.warning(
"Model change not applied to the terminal for session=%s model=%r: %s (body=%s)",
session_id,
@@ -9178,14 +9378,19 @@ async def _load_model_options(
runner_client: httpx.AsyncClient,
session_id: str,
path: str,
fallback_path: str | None = None,
) -> None:
"""
Background single-flight fetch of a session's native model catalog.
:param runner_client: HTTP client pointed at the bound runner.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
:param path: Runner route to query, e.g.
``"/v1/sessions/conv_abc/cursor-model-options"``.
:param path: Runner route to query the unified
``"/v1/sessions/conv_abc/model-options"``.
:param fallback_path: Legacy harness-named route to fall back to when
*path* 404s (an older runner without the unified route), e.g.
``"/v1/sessions/conv_abc/cursor-model-options"``. ``None`` disables
the fallback.
"""
# Read the retry schedule off the facade so tests patching
# ``sessions._MODEL_OPTIONS_RETRY_DELAYS_S`` reach this impl.
@@ -9199,6 +9404,11 @@ async def _load_model_options(
_logger.debug("Runner model-options query failed for %s", session_id)
return
if resp.status_code != 200:
# An older runner has no unified route; drop to the harness-named
# one without consuming a retry.
if resp.status_code == 404 and fallback_path and path != fallback_path:
path = fallback_path
continue
# 503 means the native backend (Codex app-server bridge / cursor
# login) is still booting. Keep the background single-flight alive
# so the web picker fills without a second manual refresh.
@@ -9328,7 +9538,10 @@ def prefetch_session_routing_catalogs(
options_task = asyncio.create_task(
_run_catalog_prefetch(
_load_model_options(
runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}"
runner_client,
session_id,
f"/v1/sessions/{session_id}/model-options",
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
),
session_id,
)
@@ -9526,7 +9739,9 @@ __all__ = [
"_persist_external_codex_collaboration_mode_change",
"_persist_external_model_change",
"_persist_external_model_options",
"_persist_external_permission_mode_change",
"_persist_external_reasoning_effort_change",
"_persist_external_session_title",
"_persist_external_subagent_start",
"_persist_native_policy_notice",
"_persist_policy_deny_sentinel",
@@ -9561,6 +9776,7 @@ __all__ = [
"_publish_interrupted",
"_publish_mcp_startup",
"_publish_model_options",
"_publish_permission_mode",
"_publish_policy_denied",
"_publish_policy_deny",
"_publish_runner_skills",
@@ -9586,6 +9802,7 @@ __all__ = [
"_require_declared_subagent",
"_require_external_status_forward",
"_require_host_conn_for_worktree",
"_require_permission_mode_forward",
"_reset_runner_resources_after_switch",
"_reset_runner_resources_after_switch_impl",
"_resolve_harness",
@@ -1221,7 +1221,13 @@ def _accumulate_session_usage(
llm_model = (
usage_model
if isinstance(usage_model, str) and usage_model
else (conv.model_override if conv and conv.model_override else _resolve_llm_model(conv))
else (
conv.reported_model
if conv and conv.reported_model
else (
conv.model_override if conv and conv.model_override else _resolve_llm_model(conv)
)
)
)
if llm_model:
if isinstance(provider_cost, (int, float)):
@@ -2538,7 +2544,7 @@ async def _mark_runner_sessions_offline_impl(
# turn edges), falling back to the row for a session whose live state
# was published before a restart.
live = _session_status_cache.get(conv.id, conv.live_status)
interrupted = live in ("running", "waiting")
interrupted = live in _MID_TURN_STATUSES
dead_on_arrival = fail_idle_top_level and conv.kind != "sub_agent"
if not interrupted and not dead_on_arrival:
continue
@@ -4253,7 +4259,12 @@ async def _refresh_stale_native_model_options(
if inflight is None:
endpoint = _MODEL_OPTIONS_ENDPOINT_BY_WRAPPER[_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE]
inflight = asyncio.create_task(
_load_model_options(runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}")
_load_model_options(
runner_client,
session_id,
f"/v1/sessions/{session_id}/model-options",
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
)
)
_model_options_inflight[session_id] = inflight
inflight.add_done_callback(
@@ -5589,6 +5600,12 @@ async def _dispatch_session_event_to_runner_impl(
RUNNER_DISCONNECT_GRACE_S: float = 10.0
# Delay between relay stream reconnect attempts inside the grace window.
_RELAY_RETRY_INTERVAL_S: float = 0.5
# Session statuses that mean a turn was in flight. A runner going away
# only interrupts work in one of these states; from any other state the
# departure is a benign disconnect, carried by liveness rather than a
# failure. ``waiting`` counts because the turn's background work (shells,
# sub-agents) outlives the turn and dies with the runner.
_MID_TURN_STATUSES = ("running", "waiting")
class _RelayTransportLost(Exception):
@@ -5604,6 +5621,48 @@ class _RelayTransportLost(Exception):
self.intentional = intentional
async def _runner_drop_interrupted_turn(
session_id: str,
conversation_store: ConversationStore,
) -> bool:
"""
Report whether a departing runner caught this session mid-turn.
Prefers the relay-fed cache the replica holding the runner's tunnel
saw the turn edges and falls back to the row for a session whose live
state was published before a restart, so a deploy mid-turn does not
downgrade a real interruption to a benign one.
An unreadable or missing row leaves the question open, and this runs
inside the disconnect handler: answering "not mid-turn" there would
both swallow the failure and let the error escape the handler, killing
the relay without publishing anything the silent truncation the
failed status exists to prevent. So an indeterminate answer reports the
drop, as the ungated relay always did.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conversation_store: Store used to read the durable live status.
:returns: ``True`` when a turn was in flight
(:data:`_MID_TURN_STATUSES`) or the state is indeterminate.
"""
cached = _session_status_cache.get(session_id)
if cached is not None:
return cached in _MID_TURN_STATUSES
try:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
except Exception: # noqa: BLE001 — an unreadable row must not kill the relay
_logger.warning(
"Relay: live-status read failed for session=%s; reporting the drop",
session_id,
exc_info=True,
)
return True
if conv is None:
return True
return conv.live_status in _MID_TURN_STATUSES
async def _relay_runner_stream(
session_id: str,
runner_client: httpx.AsyncClient,
@@ -5616,9 +5675,14 @@ async def _relay_runner_stream(
Transport drops from ingress recycles and sleep-wake reconnects
re-register the runner within :data:`RUNNER_DISCONNECT_GRACE_S`, so a
lost stream retries inside that window instead of failing the
session. The ``failed`` status (with durable ``runner_disconnected``
labels) publishes only when the runner stays gone past the grace; an
intentional Stop still exits quietly at once.
session. An intentional Stop exits quietly at once.
Past the grace the runner is genuinely gone, and only a session it
caught mid-turn (:func:`_runner_drop_interrupted_turn`) gets the
``failed`` status and durable ``runner_disconnected`` labels the same
rule :func:`_mark_runner_sessions_offline_impl` applies to the runner's
other sessions. An idle session had no work to interrupt, so it stays
idle and the disconnect surfaces through liveness instead.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
@@ -5674,6 +5738,20 @@ async def _relay_runner_stream(
None,
conversation_store,
)
elif not await _runner_drop_interrupted_turn(session_id, conversation_store):
# The runner went away while this session sat idle (host
# asleep, host restart, `omnigent host` stopped). Nothing was
# interrupted, so there is no error to report: publishing one
# lit a red "connection to the host dropped" banner over a
# session that had simply finished its last turn. The absence
# is already carried by liveness (``clear_runner_liveness``),
# which drives the reconnect affordance. Stay silent — no
# status edge, and no clearing of labels either, so a genuine
# earlier failure keeps its error.
_logger.info(
"Relay: runner gone for idle session=%s; no failure to report",
session_id,
)
else:
# Publish a failed status so the client's SSE stream sees a
# clean error event instead of silent truncation (#1114).
@@ -8910,8 +8988,16 @@ async def _fetch_model_options(
if cached is not None and session_id not in _model_options_stale:
return cached
if session_id not in _model_options_inflight:
path = f"/v1/sessions/{session_id}/{endpoint}"
task = asyncio.create_task(_load_model_options(runner_client, session_id, path))
# Unified route first; the harness-named route is the fallback for
# an older runner (deprecated aliases, removed in 0.11.0).
task = asyncio.create_task(
_load_model_options(
runner_client,
session_id,
f"/v1/sessions/{session_id}/model-options",
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
)
)
_model_options_inflight[session_id] = task
def _clear_runner_options_inflight(_task: asyncio.Task[None]) -> None:
@@ -9142,6 +9228,12 @@ async def _get_session_snapshot(
)
except Exception: # noqa: BLE001
pass
# The harness's own report is the display authority: when a session has
# a verbatim ``reported_model``, it supersedes the spec-derived value on
# the wire's ``llm_model`` field (the web renders and highlights only
# from this).
if conv.reported_model:
llm_model = conv.reported_model
# Skills are runner-owned: the bound runner discovers them against its
# own filesystem (bundled skills + host skills under the session's
# workspace and ``~/.claude/skills/``) — the host where the harness
+5 -1
View File
@@ -306,11 +306,15 @@ def create_accounts_auth_router(
# against a row that exists. Defensive-coding the dereference
# would only mask a SqlAlchemy bug, which we want to surface.
assert user is not None
body_payload = {
body_payload: dict[str, object] = {
"token": session_jwt,
"expires_in": _session_max_age,
"user": {"id": user.id, "is_admin": user.is_admin},
}
# Browser login must never receive refresh material — only CLI/device
# flows do (via /auth/cli-poll or device-grant callback). This is
# enforced server-side so an XSS or form-hijack cannot obtain
# long-lived unattended credentials.
resp = JSONResponse(status_code=200, content=body_payload)
_set_session_cookie(
resp,
+38 -8
View File
@@ -30,6 +30,7 @@ from omnigent.server.auth import (
_RESERVED_USERS,
UnifiedAuthProvider,
)
from omnigent.server.device_grant_store import DeviceGrantStore
from omnigent.server.oidc import (
_GITHUB_EMAILS_ENDPOINT,
derive_code_challenge,
@@ -37,6 +38,7 @@ from omnigent.server.oidc import (
mint_session_cookie,
)
from omnigent.server.oidc_access import OidcAdmissionPolicy, resolve_allowed_domains_path
from omnigent.server.routes.device_auth import issue_login_grant
from omnigent.stores.permission_store import PermissionStore
_logger = logging.getLogger(__name__)
@@ -67,11 +69,16 @@ class _CliTicket:
fulfills the ticket. ``None`` while pending.
:param user_id: The authenticated user's email, set when
fulfilled. ``None`` while pending.
:param refresh_token: Login-issued refresh grant material, set at
fulfillment when a grant store is wired. ``None`` while pending
or when grants are unavailable. Handed to the CLI exactly once
by the poll response.
"""
created_at: float = field(default_factory=time.time)
token: str | None = None
user_id: str | None = None
refresh_token: str | None = None
def create_auth_router(
@@ -80,6 +87,7 @@ def create_auth_router(
admin_list: AdminList,
account_store: SqlAlchemyAccountStore | None = None,
allowed_domains: frozenset[str] | None = None,
device_grant_store: DeviceGrantStore | None = None,
) -> APIRouter:
"""Create an :class:`APIRouter` with OIDC login/callback/logout routes.
@@ -100,6 +108,12 @@ def create_auth_router(
``allowed_domains:`` key, union'd with
``OMNIGENT_OIDC_ALLOWED_DOMAINS`` and the runtime-editable file
in the admission policy.
:param device_grant_store: When set, a CLI-ticket login also issues
a refresh grant (see
:func:`omnigent.server.routes.device_auth.issue_login_grant`)
so hosts and CLIs can renew without a human re-running
``omnigent login``. ``None`` keeps the legacy
session-JWT-only response.
:returns: A FastAPI router with ``/login``, ``/callback``,
``/logout`` (and ``/invite`` when invites are enabled).
"""
@@ -367,6 +381,19 @@ def create_auth_router(
ticket = _cli_tickets[ticket_id]
ticket.token = session_jwt
ticket.user_id = email
# A CLI login is a long-lived unattended credential holder
# (hosts especially) — issue a refresh grant so it can renew
# instead of dying at session-JWT expiry. Best-effort: a
# grant-store failure must not break login itself.
if device_grant_store is not None:
try:
ticket.refresh_token = issue_login_grant(
device_grant_store,
user_id=email,
cookie_secret=config.cookie_secret,
)
except Exception:
_logger.exception("cli-login: refresh grant issuance failed")
# Return a simple HTML page — the CLI is polling
# /auth/cli-poll and will pick up the token.
import html as _html
@@ -560,15 +587,18 @@ def create_auth_router(
# Fulfilled — return the token and clean up.
token = ticket.token
user_id = ticket.user_id
refresh_token = ticket.refresh_token
del _cli_tickets[ticket_id]
return JSONResponse(
status_code=200,
content={
"token": token,
"user_id": user_id,
"expires_in": config.session_ttl_hours * 3600,
},
)
content: dict[str, object] = {
"token": token,
"user_id": user_id,
"expires_in": config.session_ttl_hours * 3600,
}
# Only present when a grant store is wired — old CLIs ignore the
# extra key, new CLIs against old servers see it absent.
if refresh_token is not None:
content["refresh_token"] = refresh_token
return JSONResponse(status_code=200, content=content)
# ── Admin: read-only user list ────────────────────────────────
+410 -156
View File
@@ -53,6 +53,7 @@ import logging
import os
import secrets
import time
from collections.abc import Callable
import jwt
from fastapi import APIRouter, HTTPException, Request
@@ -80,6 +81,26 @@ _CLIENT_SECRET_HEADER = "X-Omnigent-Client-Secret"
# refuses admin / user-management paths for a token carrying this scope.
DELEGATED_SCOPE = "sessions"
# Reserved ``client_id`` for a first-party login grant (issued by
# ``omnigent login``, not the RFC 8628 device flow). It is a
# security-decision key here, so the device-authorize endpoint REFUSES a
# request that names it — a third-party device client can never obtain a
# grant tagged this way, and a refresh of such a grant is therefore safe
# to treat as first-party. See :func:`issue_login_grant`,
# ``_is_login_grant``, and the reservation guard in ``device_authorize``.
LOGIN_GRANT_CLIENT_ID = "omnigent-cli"
def _is_login_grant(client_id: str | None) -> bool:
"""Return True for a first-party login grant (vs. a device grant).
Trustworthy because :data:`LOGIN_GRANT_CLIENT_ID` is reserved: the
device-authorize path rejects it, so only the server-side login flow
can create a grant carrying it.
"""
return client_id == LOGIN_GRANT_CLIENT_ID
# RFC 8628 timings.
_DEVICE_CODE_TTL_SECONDS = 600 # 10 min — bounds the unapproved window.
_POLL_INTERVAL_SECONDS = 5 # minimum client poll interval.
@@ -91,6 +112,41 @@ _ACCESS_TOKEN_TTL_SECONDS = 3600
# re-consent through the flow. Bounds the blast radius of a leaked/phished
# grant to this window even if revocation is never called.
_GRANT_MAX_LIFETIME_SECONDS = 30 * 24 * 3600 # 30 days
# Operator override for the grant lifetime, in whole days. Deployments
# running unattended hosts can extend the re-consent window deliberately;
# the 30-day default stays the safe posture.
_GRANT_MAX_LIFETIME_ENV = "OMNIGENT_GRANT_MAX_LIFETIME_DAYS"
def _grant_max_lifetime_seconds() -> int:
"""Return the grant's absolute lifetime, honoring the env override.
Invalid or non-positive values fall back to the default auth
lifetimes must never fail open to "unbounded" on a typo.
"""
raw = os.environ.get(_GRANT_MAX_LIFETIME_ENV, "").strip()
if raw:
try:
days = int(raw)
except ValueError:
_logger.warning(
"%s=%r is not an integer — using the %d-day default",
_GRANT_MAX_LIFETIME_ENV,
raw,
_GRANT_MAX_LIFETIME_SECONDS // 86400,
)
else:
if days > 0:
return days * 86400
_logger.warning(
"%s=%r must be positive — using the %d-day default",
_GRANT_MAX_LIFETIME_ENV,
raw,
_GRANT_MAX_LIFETIME_SECONDS // 86400,
)
return _GRANT_MAX_LIFETIME_SECONDS
# user_code alphabet excludes easily-confused chars (0/O, 1/I/L).
_USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
@@ -106,10 +162,16 @@ def _client_id(body: dict[str, object]) -> str | None:
A public string naming the requesting application (e.g. Slack passes
``"slack"``), the same for every grant that application initiates.
Display + audit only never an authorization key.
Display + audit only for device grants but see
:data:`LOGIN_GRANT_CLIENT_ID`, which is reserved and refused here.
Non-string values (``{"client_id": 123}``) read as absent rather than
raising, so a malformed body is a clean 400 and not a 500.
"""
value = body.get("client_id")
return value.strip() or None if isinstance(value, str) else None
raw = body.get("client_id")
if not isinstance(raw, str):
return None
return raw.strip() or None
def _mint_refresh_token() -> str:
@@ -126,34 +188,38 @@ def mint_delegated_token(
grant_id: str,
client_id: str,
jti: str,
scope: str = DELEGATED_SCOPE,
scope: str | None = DELEGATED_SCOPE,
) -> str:
"""Mint a delegated access token for a device-authorization grant.
"""Mint a grant-derived access token.
Same HS256 shape as
:func:`omnigent.server.oidc.mint_session_token` (so
:meth:`UnifiedAuthProvider._check_cookie` validates it unchanged),
plus four delegated-only claims:
plus grant claims:
- ``scope`` restricts the token to the session APIs; the auth
layer rejects admin endpoints when this claim is present.
- ``grant_id`` the device grant this token was issued from,
checked against the revocation denylist so revoking the grant
immediately kills the token.
- ``scope`` present the auth layer restricts the token to the
session APIs and refuses admin endpoints. ``None`` omits the claim,
giving the token the SAME authority as the session JWT it renews
used ONLY for first-party login grants, whose bearer is the
authenticated user's own CLI/host, not a third-party client.
- ``grant_id`` the grant this token was issued from, checked against
the revocation denylist so revoking the grant immediately kills the
token. Carried regardless of scope.
- ``jti`` unique token id, for audit/log correlation.
- ``act`` provenance (RFC 8693 style), ``{"client_id": "<app>"}``,
naming the application that obtained the grant so every delegated
action is attributable to it.
naming the application that obtained the grant so every action is
attributable to it.
:param user_id: The Omnigent identity the token acts as (``sub``).
:param cookie_secret: HMAC key for HS256 signing.
:param ttl_seconds: Token lifetime in seconds (kept short 1 h).
:param provider: Identity provider name (informational claim).
:param grant_id: The device grant id.
:param client_id: The RFC 8628 client id (the requesting application,
:param grant_id: The grant id.
:param client_id: The client id (the requesting application,
e.g. ``"slack"``); recorded in the ``act`` claim for audit.
:param jti: Unique token id.
:param scope: Granted scope; defaults to :data:`DELEGATED_SCOPE`.
:param scope: Granted scope, or ``None`` for a full-authority
(login-grant) token. Defaults to :data:`DELEGATED_SCOPE`.
:returns: An HS256-signed JWT string.
"""
now = int(time.time())
@@ -162,11 +228,12 @@ def mint_delegated_token(
"iat": now,
"exp": now + ttl_seconds,
"provider": provider,
"scope": scope,
"grant_id": grant_id,
"jti": jti,
"act": {"client_id": client_id},
}
if scope is not None:
payload["scope"] = scope
return jwt.encode(payload, cookie_secret, algorithm="HS256")
@@ -257,6 +324,308 @@ class _SlidingWindowRateLimiter:
return True
def _resolve_signing_config(auth_provider: UnifiedAuthProvider) -> tuple[bytes, str]:
"""Return ``(cookie_secret, provider_name)`` for token minting.
Works for both server-mintable providers: ``accounts`` and ``oidc``.
Header mode has no server-held signing identity, so grant routes
cannot be built for it.
"""
if auth_provider._source == "accounts":
config = auth_provider._accounts_config
assert config is not None, "accounts mode must have an accounts config"
return config.cookie_secret, auth_provider._source
if auth_provider._source == "oidc":
oidc_config = auth_provider._oidc_config
assert oidc_config is not None, "oidc mode must have an oidc config"
return oidc_config.cookie_secret, auth_provider._source
raise RuntimeError(
f"grant routes require accounts or oidc auth (got {auth_provider._source!r})"
)
def _make_client_secret_gate() -> Callable[[Request], bool]:
"""Build the optional shared-secret check for client-facing endpoints.
Reads the env once at mount (toggling requires a restart, consistent
with the other auth env vars). Open when no secret is configured.
"""
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
if client_secret is not None:
_logger.info("device-auth: client-secret enforcement enabled")
def _client_secret_ok(request: Request) -> bool:
if client_secret is None:
return True
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
# operands, and ASGI decodes header bytes as latin-1, so a crafted
# non-ASCII header would otherwise 500 instead of cleanly failing.
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
return _client_secret_ok
def issue_login_grant(
device_grant_store: DeviceGrantStore,
*,
user_id: str,
cookie_secret: bytes,
) -> str:
"""Create a redeemed refresh grant for an interactive login.
Called by the login flows (OIDC cli-ticket fulfillment, accounts
``/auth/login``) so the CLI walks away with refresh material and can
renew its access without a human re-running ``omnigent login``. The
interactive login *is* the consent step, so the grant is born
``redeemed`` and tagged with the reserved
:data:`LOGIN_GRANT_CLIENT_ID` which the device-authorize path
refuses, so this authority class can only originate here.
:param device_grant_store: Grant persistence.
:param user_id: The just-authenticated identity.
:param cookie_secret: HMAC key for hashing the refresh token.
:returns: The raw refresh token to hand to the client (stored hashed).
"""
refresh_token = _mint_refresh_token()
device_grant_store.create_redeemed_grant(
secrets.token_urlsafe(24),
user_id=user_id,
client_id=LOGIN_GRANT_CLIENT_ID,
refresh_token_hash=hash_secret(refresh_token, cookie_secret),
created_at=int(time.time()),
)
return refresh_token
def create_oauth_token_router(
auth_provider: UnifiedAuthProvider,
device_grant_store: DeviceGrantStore,
*,
handle_device_code: Callable[[str], Response] | None = None,
client_secret_ok: Callable[[Request], bool] | None = None,
) -> APIRouter:
"""Build the ``/oauth/token`` + ``/oauth/revoke`` router.
The refresh/revoke half of the grant machinery, mountable on its own:
login-issued refresh grants (see :func:`issue_login_grant`) need these
endpoints in **both** accounts and OIDC modes, independent of the
RFC 8628 device-code consent flow (which stays accounts-only behind
``OMNIGENT_DEVICE_GRANT_ENABLED``).
:param auth_provider: The active provider ``accounts`` or ``oidc``.
:param device_grant_store: Persistence for grants.
:param handle_device_code: Optional device-code grant handler.
:func:`create_device_auth_router` passes its polling closure so
the full flow keeps one token endpoint; standalone mounts leave
it ``None`` and ``device_code`` exchanges get
``unsupported_grant_type``.
:param client_secret_ok: Optional client-secret gate (callable that validates
the request). When ``None`` (standalone mounts), builds the gate from
the ``OMNIGENT_DEVICE_CLIENT_SECRET`` env var. When provided
(from :func:`create_device_auth_router`), reuses the gate to avoid
duplication.
:returns: APIRouter to mount at the app root.
"""
cookie_secret, provider_name = _resolve_signing_config(auth_provider)
_client_secret_ok = client_secret_ok or _make_client_secret_gate()
# Resolve grant max lifetime once at mount time (not on every refresh).
_grant_max_lifetime = _grant_max_lifetime_seconds()
router = APIRouter()
# Throttle for the opportunistic grant purge (bounds the table without a
# separate scheduler). Mutable one-field dict so the closures can update
# it. ``0.0`` forces a purge on the first refresh after boot.
_last_purge = {"at": 0.0}
def _maybe_purge() -> None:
"""Purge expired/aged grants at most once per interval.
The device flow purges on ``/oauth/device/authorize``, but the
standalone token router (OIDC, or accounts without the device flow)
has no authorize route so login grants would otherwise accumulate
one row per login. Piggyback the purge on refresh instead.
"""
now_wall = time.time()
if now_wall - _last_purge["at"] < _PURGE_MIN_INTERVAL_SECONDS:
return
_last_purge["at"] = now_wall
try:
device_grant_store.purge_expired(
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
)
except Exception: # noqa: BLE001 — housekeeping must never fail a refresh
_logger.debug("oauth/token: opportunistic grant purge failed", exc_info=True)
def _issue_access_token(grant_id: str, user_id: str, client_id: str) -> str:
# A first-party login grant renews with the SAME authority as the
# session JWT it replaces (scope=None); a third-party device grant
# stays restricted to the delegated allowlist.
scope = None if _is_login_grant(client_id) else DELEGATED_SCOPE
return mint_delegated_token(
user_id,
cookie_secret,
_ACCESS_TOKEN_TTL_SECONDS,
provider_name,
grant_id=grant_id,
client_id=client_id or "",
jti=secrets.token_urlsafe(16),
scope=scope,
)
@router.post("/oauth/token", dependencies=[])
async def token(request: Request) -> Response:
"""Exchange a device_code or refresh_token for an access token.
RFC 8628 / 6749 error shapes: ``authorization_pending``,
``slow_down``, ``expired_token``, ``access_denied``,
``invalid_grant``, ``unsupported_grant_type``.
"""
form = await request.form()
grant_type = str(form.get("grant_type") or "")
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
# The device-code exchange mints from the ephemeral device_code
# alone, so it stays behind the client-secret gate. Refresh
# presents the refresh token itself as the credential, so it is
# not additionally gated (a CLI/host renewing its own login has
# no way to carry the device client secret).
if not _client_secret_ok(request):
return _oauth_error("invalid_client", status_code=401)
if handle_device_code is None:
return _oauth_error("unsupported_grant_type")
return handle_device_code(str(form.get("device_code") or ""))
if grant_type == "refresh_token":
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
return _oauth_error("unsupported_grant_type")
def _handle_refresh_grant(refresh_token: str) -> Response:
if not refresh_token:
return _oauth_error("invalid_request")
# Opportunistic housekeeping so login-grant rows (one per login)
# don't accumulate where no device-authorize purge runs.
_maybe_purge()
presented_hash = hash_secret(refresh_token, cookie_secret)
# A refresh token doesn't name its grant, so locate it by digest.
# Only a live (redeemed, non-revoked) grant holds a matching hash.
grant = device_grant_store.get_by_refresh_hash(presented_hash)
if grant is None:
# Not the current token. If it matches a grant's *previous*
# token, a stale token was replayed — reuse/theft. Revoke the
# whole grant so the attacker's freshly-rotated token dies too.
# (Login grants don't rotate, so they never populate the prev
# hash and can't reach this revoke.)
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
if stale is not None:
device_grant_store.revoke(stale.id)
_logger.warning(
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
)
return _oauth_error("invalid_grant")
# Refuse to refresh a grant past its absolute lifetime — the user
# must re-consent. Checked before rotating so an aged grant simply
# stops working (it is NOT reuse, so it must not revoke/oscillate).
if grant.approved_at is not None and (
int(time.time()) - grant.approved_at >= _grant_max_lifetime
):
return _oauth_error("expired_token")
if grant.user_id is None:
return _oauth_error("invalid_grant")
if _is_login_grant(grant.client_id):
# First-party login grants do NOT rotate. The bearer is an
# unattended host/CLI, so a lost refresh response (network blip,
# crash between the server committing and the client persisting)
# must not brick the grant via reuse detection. The same refresh
# token stays valid for the grant lifetime; only the short-lived
# access token is renewed. Revocation + the absolute lifetime cap
# bound the exposure.
access_token = _issue_access_token(grant.id, grant.user_id, grant.client_id or "")
return JSONResponse(
status_code=200,
content={
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
},
)
new_refresh = _mint_refresh_token()
rotated = device_grant_store.rotate_refresh_token(
grant.id,
expected_hash=presented_hash,
new_hash=hash_secret(new_refresh, cookie_secret),
now_epoch_seconds=int(time.time()),
max_lifetime_seconds=_grant_max_lifetime,
)
if rotated is None:
# Lost a concurrent rotation race, or the grant aged out between
# the check above and here — reject without revoking (this is not
# a reuse signal, so the grant must not be killed/oscillate).
return _oauth_error("invalid_grant")
if rotated.user_id is None:
return _oauth_error("invalid_grant")
access_token = _issue_access_token(
rotated.id,
rotated.user_id,
rotated.client_id or "",
)
return JSONResponse(
status_code=200,
content={
"access_token": access_token,
"refresh_token": new_refresh,
"token_type": "Bearer",
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
},
)
# ── Revocation ────────────────────────────────────────────────
@router.post("/oauth/revoke", dependencies=[])
async def revoke(request: Request) -> Response:
"""Revoke a grant by refresh token or by the caller's access token.
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
field; falls back to the ``grant_id`` on the caller's own
delegated access token so a client with only its access token
can still log out. Not behind the device client-secret gate: the
presented refresh/access token IS the credential, and a CLI
logging out its own login grant cannot carry that secret.
"""
form = await request.form()
refresh_token = str(form.get("refresh_token") or "")
grant = None
if refresh_token:
grant = device_grant_store.get_by_refresh_hash(
hash_secret(refresh_token, cookie_secret)
)
if grant is None:
grant_id = _grant_id_from_bearer(request)
if grant_id is not None:
grant = device_grant_store.get_by_id(grant_id)
if grant is None:
# Idempotent: nothing to revoke is still "revoked" from the
# caller's perspective. Don't leak which tokens exist.
return JSONResponse(status_code=200, content={"revoked": True})
device_grant_store.revoke(grant.id)
_logger.info("oauth/revoke: revoked grant %s", grant.id)
return JSONResponse(status_code=200, content={"revoked": True})
def _grant_id_from_bearer(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
try:
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
grant_id = payload.get("grant_id")
return grant_id if isinstance(grant_id, str) else None
return router
def create_device_auth_router(
auth_provider: UnifiedAuthProvider,
device_grant_store: DeviceGrantStore,
@@ -278,28 +647,9 @@ def create_device_auth_router(
base_url = cookie_config.base_url
provider_name = auth_provider._source
# Read the optional client secret once at mount. When set, the
# client-facing endpoints require a matching header; when unset they
# stay public. Captured here (not per-request) so toggling it needs a
# restart — consistent with the other auth env vars.
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
if client_secret is not None:
_logger.info("device-auth: client-secret enforcement enabled")
def _client_secret_ok(request: Request) -> bool:
"""Return True if the request may use the client-facing endpoints.
Open when no secret is configured; otherwise requires the presented
header to match, compared in constant time to avoid leaking the
secret through timing.
"""
if client_secret is None:
return True
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
# operands, and ASGI decodes header bytes as latin-1, so a crafted
# non-ASCII header would otherwise 500 instead of cleanly failing.
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
_client_secret_ok = _make_client_secret_gate()
# Resolve grant max lifetime once at mount (not on every purge).
_grant_max_lifetime = _grant_max_lifetime_seconds()
router = APIRouter()
_rate_limiter = _SlidingWindowRateLimiter(
@@ -347,7 +697,7 @@ def create_device_auth_router(
_last_purge["at"] = now_wall
try:
device_grant_store.purge_expired(
int(now_wall), max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
)
except Exception: # housekeeping must never fail a request
_logger.exception("device grant purge failed")
@@ -359,6 +709,15 @@ def create_device_auth_router(
if not isinstance(body, dict):
body = {}
client_id = _client_id(body)
# LOGIN_GRANT_CLIENT_ID marks a first-party login grant, whose
# refreshed tokens carry full session authority. It is reserved:
# a device client must never be able to self-declare into that
# class by naming it here.
if _is_login_grant(client_id):
_logger.warning(
"device/authorize: refused reserved client_id %r", LOGIN_GRANT_CLIENT_ID
)
return _oauth_error("invalid_request")
device_code = secrets.token_urlsafe(32)
grant_id = secrets.token_urlsafe(16)
@@ -543,26 +902,10 @@ def create_device_auth_router(
device_grant_store.deny(grant.id)
return HTMLResponse(_consent_html(denied=True), status_code=200)
# ── Token endpoint (client polling + refresh) ─────────────────
@router.post("/oauth/token", dependencies=[])
async def token(request: Request) -> Response:
"""Exchange a device_code or refresh_token for an access token.
RFC 8628 / 6749 error shapes: ``authorization_pending``,
``slow_down``, ``expired_token``, ``access_denied``,
``invalid_grant``, ``unsupported_grant_type``.
"""
if not _client_secret_ok(request):
return _oauth_error("invalid_client", status_code=401)
form = await request.form()
grant_type = str(form.get("grant_type") or "")
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
return _handle_device_code_grant(str(form.get("device_code") or ""))
if grant_type == "refresh_token":
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
return _oauth_error("unsupported_grant_type")
# ── Token + revocation endpoints ──────────────────────────────
# Shared with the standalone login-grant mount: the device flow's
# only addition is the device_code grant handler, injected here so
# /oauth/token stays a single registration either way.
def _handle_device_code_grant(device_code: str) -> Response:
if not device_code:
@@ -613,103 +956,14 @@ def create_device_auth_router(
},
)
def _handle_refresh_grant(refresh_token: str) -> Response:
if not refresh_token:
return _oauth_error("invalid_request")
presented_hash = hash_secret(refresh_token, cookie_secret)
# A refresh token doesn't name its grant, so locate it by digest.
# Only a live (redeemed, non-revoked) grant holds a matching hash.
grant = device_grant_store.get_by_refresh_hash(presented_hash)
if grant is None:
# Not the current token. If it matches a grant's *previous*
# token, a stale token was replayed — reuse/theft. Revoke the
# whole grant so the attacker's freshly-rotated token dies too.
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
if stale is not None:
device_grant_store.revoke(stale.id)
_logger.warning(
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
)
return _oauth_error("invalid_grant")
# Refuse to refresh a grant past its absolute lifetime — the user
# must re-consent. Checked before rotating so an aged grant simply
# stops working (it is NOT reuse, so it must not revoke/oscillate).
if grant.approved_at is not None and (
int(time.time()) - grant.approved_at >= _GRANT_MAX_LIFETIME_SECONDS
):
return _oauth_error("expired_token")
new_refresh = _mint_refresh_token()
rotated = device_grant_store.rotate_refresh_token(
grant.id,
expected_hash=presented_hash,
new_hash=hash_secret(new_refresh, cookie_secret),
now_epoch_seconds=int(time.time()),
max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS,
router.include_router(
create_oauth_token_router(
auth_provider,
device_grant_store,
handle_device_code=_handle_device_code_grant,
client_secret_ok=_client_secret_ok,
)
if rotated is None:
# Lost a concurrent rotation race, or the grant aged out between
# the check above and here — reject without revoking (this is not
# a reuse signal, so the grant must not be killed/oscillate).
return _oauth_error("invalid_grant")
if rotated.user_id is None:
return _oauth_error("invalid_grant")
access_token = _issue_access_token(
rotated.id,
rotated.user_id,
rotated.client_id or "",
)
return JSONResponse(
status_code=200,
content={
"access_token": access_token,
"refresh_token": new_refresh,
"token_type": "Bearer",
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
},
)
# ── Revocation ────────────────────────────────────────────────
@router.post("/oauth/revoke", dependencies=[])
async def revoke(request: Request) -> Response:
"""Revoke a grant by refresh token or by the caller's access token.
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
field; falls back to the ``grant_id`` on the caller's own
delegated access token so a client with only its access token
can still log out.
"""
if not _client_secret_ok(request):
return _oauth_error("invalid_client", status_code=401)
form = await request.form()
refresh_token = str(form.get("refresh_token") or "")
grant = None
if refresh_token:
grant = device_grant_store.get_by_refresh_hash(
hash_secret(refresh_token, cookie_secret)
)
if grant is None:
grant_id = _grant_id_from_bearer(request)
if grant_id is not None:
grant = device_grant_store.get_by_id(grant_id)
if grant is None:
# Idempotent: nothing to revoke is still "revoked" from the
# caller's perspective. Don't leak which tokens exist.
return JSONResponse(status_code=200, content={"revoked": True})
device_grant_store.revoke(grant.id)
_logger.info("oauth/revoke: revoked grant %s", grant.id)
return JSONResponse(status_code=200, content={"revoked": True})
def _grant_id_from_bearer(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
try:
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
grant_id = payload.get("grant_id")
return grant_id if isinstance(grant_id, str) else None
)
return router
+17 -2
View File
@@ -681,7 +681,7 @@ def create_hosts_router(
request: Request,
host_id: str,
harness: str,
) -> dict[str, list[dict[str, Any]]]:
) -> dict[str, list[Any]]:
"""Return pre-launch model choices resolved by the selected host.
A preview of the host's ambient default catalog, not a binding
@@ -709,7 +709,22 @@ def create_hosts_router(
detail=str(result.get("error") or "host model-options lookup failed"),
)
models = result.get("models")
return {"models": models if isinstance(models, list) else []}
routable = result.get("routable_models")
payload: dict[str, Any] = {
"models": models if isinstance(models, list) else [],
# Every id the harness's endpoint routes: the picker names one
# row per model, while a launch takes an exact id.
"routable_models": (
[m for m in routable if isinstance(m, str)] if isinstance(routable, list) else []
),
}
# An honest empty answer carries the reason (e.g. "the codex model
# probe failed — see the host log") so the picker can say WHY it is
# empty instead of a generic "Models unavailable".
error = result.get("error")
if isinstance(error, str) and error:
payload["error"] = error
return payload
@router.post("/hosts/{host_id}/runners")
async def launch_runner(
@@ -52,6 +52,7 @@ class CreateScheduledTaskRequest(BaseModel):
timezone: str = "UTC"
model_override: str | None = None
reasoning_effort: str | None = None
max_cost_usd: float | None = Field(default=None, gt=0)
# Optional: no PINNED host/workspace. When both are unset the fire path
# resolves the owner's online host at fire time and defaults the workspace to
# that host's home directory (a failed run is recorded if none is online) —
@@ -74,6 +75,7 @@ class UpdateScheduledTaskRequest(BaseModel):
timezone: str | None = None
model_override: str | None = None
reasoning_effort: str | None = None
max_cost_usd: float | None = Field(default=None, gt=0) # null clears the cap
workspace: str | None = Field(default=None, min_length=1)
host_id: str | None = Field(default=None, min_length=1)
state: str | None = None
@@ -121,6 +123,7 @@ def _to_response(
"created_at": task.created_at,
"model_override": task.model_override,
"reasoning_effort": task.reasoning_effort,
"max_cost_usd": task.max_cost_usd,
"workspace": task.workspace,
"host_id": task.host_id,
"state": task.state,
@@ -311,6 +314,7 @@ def create_scheduled_tasks_router(
timezone=body.timezone,
model_override=model_override,
reasoning_effort=reasoning_effort,
max_cost_usd=body.max_cost_usd,
workspace=workspace,
host_id=body.host_id,
)
@@ -187,6 +187,8 @@ from omnigent.server.routes._sessions.common import (
_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S as _CLAUDE_NATIVE_MESSAGE_TIMEOUT_S,
_CLAUDE_NATIVE_MODEL as _CLAUDE_NATIVE_MODEL,
_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S as _CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
_CLAUDE_NATIVE_PERMISSION_MODES as _CLAUDE_NATIVE_PERMISSION_MODES,
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY as _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS as _CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY as _CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE as _CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
@@ -236,6 +238,7 @@ from omnigent.server.routes._sessions.common import (
_EXTERNAL_SESSION_STATUS_TYPE as _EXTERNAL_SESSION_STATUS_TYPE,
_EXTERNAL_SESSION_STATUS_VALUES as _EXTERNAL_SESSION_STATUS_VALUES,
_EXTERNAL_SESSION_SUPERSEDED_TYPE as _EXTERNAL_SESSION_SUPERSEDED_TYPE,
_EXTERNAL_SESSION_TITLE_TYPE as _EXTERNAL_SESSION_TITLE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE as _EXTERNAL_SESSION_TODOS_TYPE,
_EXTERNAL_SESSION_USAGE_TYPE as _EXTERNAL_SESSION_USAGE_TYPE,
_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT as _EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT,
@@ -437,6 +440,7 @@ from omnigent.server.routes._sessions.helpers import (
_persist_external_model_change as _persist_external_model_change,
_persist_external_model_options as _persist_external_model_options,
_persist_external_reasoning_effort_change as _persist_external_reasoning_effort_change,
_persist_external_session_title as _persist_external_session_title,
_persist_external_subagent_start as _persist_external_subagent_start,
_persist_native_policy_notice as _persist_native_policy_notice,
_persist_policy_deny_sentinel as _persist_policy_deny_sentinel,
@@ -493,6 +497,7 @@ from omnigent.server.routes._sessions.helpers import (
_require_declared_subagent as _require_declared_subagent,
_require_external_status_forward as _require_external_status_forward,
_require_host_conn_for_worktree as _require_host_conn_for_worktree,
_require_permission_mode_forward as _require_permission_mode_forward,
_reset_runner_resources_after_switch_impl as _reset_runner_resources_after_switch_impl,
_resolve_llm_model as _resolve_llm_model,
_resolve_skill_meta_text_via_runner as _resolve_skill_meta_text_via_runner,
+64 -1
View File
@@ -94,9 +94,12 @@ from omnigent.server.routes._content_type import (
from omnigent.server.routes._errors import session_not_found as _session_not_found
from omnigent.server.routes._origin import require_trusted_origin
from omnigent.server.routes._sessions.common import (
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
_CLAUDE_NATIVE_PERMISSION_MODES,
_CLAUDE_NATIVE_UI_LABEL_KEY,
_CLAUDE_NATIVE_UI_LABEL_VALUE,
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY,
_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE,
_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY,
_CODEX_NATIVE_COLLABORATION_MODES,
_CODEX_NATIVE_WRAPPER_LABEL_VALUE,
@@ -125,12 +128,14 @@ from omnigent.server.routes._sessions.helpers import (
_presentation_labels_for_agent,
_prune_session_read_state,
_publish_collaboration_mode,
_publish_permission_mode,
_publish_sandbox_status,
_publish_terminal_pending,
_reject_reserved_cost_control_label_seed,
_reject_server_reserved_label_seed,
_require_collaboration_mode_forward,
_require_cost_control_label_authority,
_require_permission_mode_forward,
_reset_runner_resources_after_switch,
_same_provider_family,
_session_status_from_cache,
@@ -648,7 +653,12 @@ def register_core_routes(
by_name: dict[str, SessionProjectSummary] = {}
if project_store is not None:
for proj in project_store.list(user_id=user_id):
by_name[proj.name] = SessionProjectSummary(id=proj.id, name=proj.name)
icon = proj.config.get("icon")
by_name[proj.name] = SessionProjectSummary(
id=proj.id,
name=proj.name,
icon=icon if isinstance(icon, str) else None,
)
# Legacy path: label-derived projects (id=None unless already first-class).
for name in conversation_store.list_projects(owned_by=user_id):
by_name.setdefault(name, SessionProjectSummary(id=None, name=name))
@@ -1593,6 +1603,34 @@ def register_core_routes(
code=ErrorCode.INVALID_INPUT,
)
requested_codex_collaboration_mode = body.collaboration_mode
permission_mode_requested = "permission_mode" in body.model_fields_set
requested_claude_permission_mode: str | None = None
if permission_mode_requested:
if body.permission_mode is None:
raise OmnigentError(
"permission_mode must be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
if body.permission_mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
raise OmnigentError(
f"permission_mode must be one of {sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}",
code=ErrorCode.INVALID_INPUT,
)
conv_for_permission_mode = await asyncio.to_thread(
conversation_store.get_conversation,
session_id,
)
if conv_for_permission_mode is None:
raise _session_not_found()
if (
conv_for_permission_mode.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
!= _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE
):
raise OmnigentError(
"permission_mode is only supported for claude-native sessions",
code=ErrorCode.INVALID_INPUT,
)
requested_claude_permission_mode = body.permission_mode
labels_to_set = dict(body.labels or {})
# Pins are per-user. The client writes the canonical ``omnigent.pinned``
# key; rewrite it to the caller's per-user key so one user's pin doesn't
@@ -1871,6 +1909,24 @@ def register_core_routes(
_codex_plan_enabled,
_runner_result,
)
if requested_claude_permission_mode is not None and live_forward:
_mode_result = await _forward_session_change_to_runner(
session_id,
runner_router,
{
"type": "permission_mode_change",
"permission_mode": requested_claude_permission_mode,
},
)
# Raises unless the runner confirms the switch, so the label can
# never claim a mode Claude isn't in. Stores the mode it reached.
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY] = (
_require_permission_mode_forward(
session_id,
requested_claude_permission_mode,
_mode_result,
)
)
# Some labels are cleared by DELETE, not by upserting an empty value:
# the project membership (empty = "remove from project") and the pinned
# flag (empty = "unpin"). Split any empty-valued clear keys out before
@@ -1884,6 +1940,13 @@ def register_core_routes(
await asyncio.to_thread(conversation_store.delete_label, session_id, _clear_key)
if labels_to_set:
await asyncio.to_thread(conversation_store.set_labels, session_id, labels_to_set)
# Only when the switch was forwarded: a silent PATCH writes no label,
# and an unconfirmed mode must not reach the picker.
if _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY in labels_to_set:
_publish_permission_mode(
session_id,
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY],
)
# Archiving means "get this out of my way", which contradicts a pin
# ("keep it at the top"), so drop the archiver's own pin — otherwise the
# session lingers as a pinned row if later unarchived. Runs after the
@@ -93,11 +93,13 @@ from omnigent.server.routes._sessions.common import (
_EXTERNAL_MODEL_OPTIONS_TYPE,
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE,
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_INTERRUPTED_TYPE,
_EXTERNAL_SESSION_STATUS_TYPE,
_EXTERNAL_SESSION_STATUS_VALUES,
_EXTERNAL_SESSION_SUPERSEDED_TYPE,
_EXTERNAL_SESSION_TITLE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
_EXTERNAL_SESSION_USAGE_TYPE,
_EXTERNAL_SUBAGENT_START_TYPE,
@@ -139,7 +141,9 @@ from omnigent.server.routes._sessions.helpers import (
_persist_external_codex_collaboration_mode_change,
_persist_external_model_change,
_persist_external_model_options,
_persist_external_permission_mode_change,
_persist_external_reasoning_effort_change,
_persist_external_session_title,
_persist_external_subagent_start,
_persist_policy_deny_sentinel,
_persist_session_status_error_labels,
@@ -536,7 +540,9 @@ def register_events_routes(
_EXTERNAL_MCP_STARTUP_TYPE,
_EXTERNAL_MODEL_CHANGE_TYPE,
_EXTERNAL_MODEL_OPTIONS_TYPE,
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_TITLE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
_EXTERNAL_SUBAGENT_START_TYPE,
_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
@@ -1183,6 +1189,22 @@ def register_events_routes(
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_PERMISSION_MODE_CHANGE_TYPE:
await _persist_external_permission_mode_change(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_TITLE_TYPE:
await _persist_external_session_title(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_MODEL_OPTIONS_TYPE:
_persist_external_model_options(session_id, conv, body)
return {"queued": False}
+21 -15
View File
@@ -32,10 +32,12 @@ to resolving the terminal in the local registry.
Wire protocol on the WebSocket
------------------------------
- **Server client**: every PTY read is forwarded as a *binary*
WebSocket frame. xterm.js's ``term.write()`` accepts ``Uint8Array``
directly and runs it through its ANSI parser, so colors, cursor
motion, alternate screen, mouse modes all work transparently.
- **Server client**: terminal output is forwarded as *binary* WebSocket
frames. xterm.js's ``term.write()`` accepts ``Uint8Array`` directly and runs
it through its ANSI parser, so colors, cursor motion, alternate screen, and
mouse modes work transparently. Control-mode attaches may also send a text
``clipboard-write`` JSON frame after tmux reports a copy-mode selection; PTY
attaches advertise whether OSC 52 is safe before terminal output begins.
- **Client server**:
- **Text frames** are JSON control messages:
``{"type": "resize", "cols": N, "rows": M}``. Parsed and applied
@@ -274,17 +276,21 @@ def create_terminal_attach_router(
"terminal.transport": resolved_transport,
},
):
bridge = (
bridge_tmux_control_to_websocket
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
else bridge_tmux_pty_to_websocket
)
await bridge(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
)
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
await bridge_tmux_control_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
)
else:
await bridge_tmux_pty_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
)
return router
+24 -2
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Request
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY
from omnigent.entities import Conversation
from omnigent.runtime.policies.builder import load_session_usage
from omnigent.runtime.policies.builder import load_session_tree, load_session_usage
from omnigent.server.auth import RESERVED_USER_LOCAL, AuthProvider
from omnigent.server.feature_flags import Feature, FeatureFlags, resolve_feature_flags
from omnigent.server.routes._auth_helpers import require_user
@@ -65,6 +65,22 @@ def _session_models(usage: dict[str, Any]) -> dict[str, float]:
return models
def _collect_other_harnesses(
primary: str | None,
tree: list[Conversation],
root_id: str,
) -> list[str] | None:
"""Distinct harnesses used by sub-agents, excluding the primary."""
seen: set[str] = set()
for conv in tree:
if conv.id == root_id:
continue
h = _resolve_session_harness(conv)
if h and h != primary:
seen.add(h)
return sorted(seen) if seen else None
def _resolve_session_harness(conv: Conversation) -> str | None:
"""
Best-effort harness resolution for the usage report.
@@ -152,6 +168,11 @@ def _build_usage_report(
if conv.agent_id is None:
continue
usage = load_session_usage(conv.id, conversation_store)
primary_harness = _resolve_session_harness(conv) if include_page_details else None
other_harnesses = None
if include_page_details:
tree = load_session_tree(conv.id, conversation_store)
other_harnesses = _collect_other_harnesses(primary_harness, tree, conv.id)
sessions.append(
SessionUsage(
id=conv.id,
@@ -160,7 +181,8 @@ def _build_usage_report(
title=conv.title,
cost_usd=_session_cost(usage),
models=_session_models(usage),
harness=_resolve_session_harness(conv) if include_page_details else None,
harness=primary_harness,
other_harnesses=other_harnesses,
llm_model=(
conv.model_override or _resolve_llm_model(conv)
if include_page_details
+35
View File
@@ -120,6 +120,7 @@ class FireDeps:
permission_store: Any | None
host_store: Any | None
host_registry: Any | None
policy_store: Any | None = None
agent_cache: Any | None = None
runner_router: Any | None = None
tunnel_registry: Any | None = None
@@ -425,6 +426,8 @@ async def _run_fire_for_task(
)
return
await _attach_cost_budget(deps, task, conv.id)
try:
await _grant_owner(deps, task, conv.id)
except Exception:
@@ -607,6 +610,38 @@ async def _create_session(deps: FireDeps, task: ScheduledTask) -> Conversation:
return conv
_COST_BUDGET_HANDLER = "omnigent.policies.builtins.cost.cost_budget"
_COST_BUDGET_POLICY_NAME = "__scheduled_task_cost_budget"
async def _attach_cost_budget(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
"""Attach a cost_budget policy to a session spawned by a scheduled task.
Non-fatal: a failure logs a warning but does not fail the fire an
uncapped session is better than a dead run.
"""
if task.max_cost_usd is None or deps.policy_store is None:
return
try:
await asyncio.to_thread(
deps.policy_store.create,
policy_id=_new_id(),
session_id=conversation_id,
name=_COST_BUDGET_POLICY_NAME,
type="python",
handler=_COST_BUDGET_HANDLER,
factory_params={"max_cost_usd": task.max_cost_usd},
enabled=True,
)
except Exception: # noqa: BLE001
_logger.warning(
"scheduled fire: failed to attach cost budget for task %s (session %s)",
task.id,
conversation_id,
exc_info=True,
)
async def _grant_owner(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
"""Write the LEVEL_OWNER grant so the run is visible to its owner.
+83 -17
View File
@@ -1485,7 +1485,7 @@ class SessionCreateMetadata(BaseModel):
:param reasoning_effort: Optional per-session reasoning-effort
hint. Accepted metadata values are ``"none"``,
``"minimal"``, ``"low"``, ``"medium"``, ``"high"``,
``"xhigh"``, and ``"max"``. Provider-specific support is
``"xhigh"``, ``"max"``, and ``"ultra"``. Provider-specific support is
validated when a turn executes. ``None`` means use the agent
default.
:param host_id: Optional host to launch the runner on, e.g.
@@ -1720,10 +1720,13 @@ class SessionResponse(BaseModel):
permission level on this session: ``1`` = read, ``2`` =
edit, ``3`` = manage. ``None`` when permissions are
disabled (single-user mode without a permission store).
:param llm_model: The LLM model identifier from the bound
agent's spec, e.g. ``"anthropic/claude-sonnet-4-6"``.
``None`` when the agent has no explicit ``llm:`` block or
the agent cannot be looked up.
:param llm_model: The model this session is actually on. When the
harness has reported one (``reported_model``, written by
``external_model_change``), that verbatim value serves here
and is the only model value clients display; otherwise the
bound agent spec's model, e.g.
``"anthropic/claude-sonnet-4-6"``. ``None`` when neither
exists.
:param harness: The bound agent's canonical harness, e.g.
``"claude-sdk"`` or ``"openai-agents"``. Lets the client
render the active credential for the correct provider
@@ -1977,6 +1980,15 @@ class UpdateSessionRequest(BaseModel):
``"plan"`` enters Plan mode and ``"default"`` returns to Default
mode for subsequent Codex turns. Only valid for sessions stamped
with the codex-native wrapper label. Omitted leaves unchanged.
:param permission_mode: Claude-native permission mode to switch a
running session to, e.g. ``"auto"``. Only the modes Claude Code's
shift+tab cycle can reach are accepted (``default``,
``acceptEdits``, ``plan``, ``auto``) ``dontAsk`` and
``bypassPermissions`` are launch-only. Only valid for sessions
stamped with the claude-native wrapper label. Unlike the other
fields here the switch is applied by the live TUI, so a failure
to reach the mode is surfaced as an error rather than persisted.
Omitted leaves unchanged.
:param cost_control_mode_override: Per-session cost-control
switch: ``"on"`` activates the spec's configured cost-control
mode, ``"off"`` disables cost control for this session.
@@ -2034,6 +2046,7 @@ class UpdateSessionRequest(BaseModel):
reasoning_effort: str | None = None
model_override: str | None = None
collaboration_mode: str | None = None
permission_mode: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
external_session_id: str | None = None
@@ -2440,6 +2453,7 @@ class SessionUsage(BaseModel):
cost_usd: float = 0.0
models: dict[str, float] = Field(default_factory=dict)
harness: str | None = None
other_harnesses: list[str] | None = None
llm_model: str | None = None
agent_name: str | None = None
@@ -2724,24 +2738,24 @@ class SessionUsageEvent(_SSEEventBase):
class SessionModelEvent(_SSEEventBase):
"""
Active-model update from a terminal-backed integration.
Active-model report from a terminal-backed integration.
Emitted after an ``external_model_change`` POST from the
``omnigent claude`` transcript forwarder when the model is
switched inside the Claude Code terminal (a ``/model`` command or
the in-TUI picker). Lets the web model picker reflect a TUI-side
switch without a reload.
Emitted after an ``external_model_change`` POST from a native
forwarder the launch's own model report, or a switch made inside
the pane (a ``/model`` command or the in-TUI picker). Every surface
re-renders its model display from this.
:param type: Always ``"session.model"``.
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
:param model: Tier alias the session is now on, e.g. ``"opus"``
Claude Code's version-agnostic alias, matching the picker's
vocabulary (not a pinned ``"claude-opus-4-8"`` id).
:param model: The model the harness reports the session is on,
VERBATIM in the harness's own spelling, e.g.
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"`` never
collapsed to a picker alias.
Category: **transient** (SSE-only). The server also writes
``model_override`` on the conversation, so on reconnect clients
restore the selection from the snapshot's ``model_override`` rather
than from a replayed event.
``reported_model`` on the conversation (served on the snapshot's
``llm_model``), so on reconnect clients restore the display from
the snapshot rather than from a replayed event.
"""
type: Literal["session.model"]
@@ -2749,6 +2763,29 @@ class SessionModelEvent(_SSEEventBase):
model: str
class SessionTitleEvent(_SSEEventBase):
"""
Session-title update from a terminal-backed integration.
Emitted after an ``external_session_title`` POST from the
``omnigent claude`` transcript forwarder when the operator renames
the session inside the Claude Code pane (``/rename``). Lets the web
session list show the new name without a reload.
:param type: Always ``"session.title"``.
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
:param title: Title the session is now on, e.g. ``"auth-refactor"``.
Category: **transient** (SSE-only). The server also writes ``title``
on the conversation, so on reconnect clients restore the name from
the session snapshot rather than from a replayed event.
"""
type: Literal["session.title"]
conversation_id: str
title: str
class SessionReasoningEffortEvent(_SSEEventBase):
"""
Active reasoning-effort update from a terminal-backed integration.
@@ -2797,6 +2834,29 @@ class SessionCollaborationModeEvent(_SSEEventBase):
mode: str
class SessionPermissionModeEvent(_SSEEventBase):
"""
Active permission-mode update from a claude-native session.
Emitted after the web UI switches the mode, and after the Claude forwarder
observes a different mode in the pane footer a shift+tab pressed inside
the TUI, which Omnigent has no other way to see. Lets the composer's mode
picker track the pane without a reload.
:param type: Always ``"session.permission_mode"``.
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
:param permission_mode: The active mode, e.g. ``"auto"`` or ``"plan"``.
Category: **transient** (SSE-only). The server also writes
``omnigent.claude_native.permission_mode`` on the conversation labels, so
reconnecting clients restore the same state from the session snapshot.
"""
type: Literal["session.permission_mode"]
conversation_id: str
permission_mode: str
class SessionAgentChangedEvent(_SSEEventBase):
"""
Bound-agent change on a live session.
@@ -4186,8 +4246,10 @@ ServerStreamEvent = Annotated[
SessionStatusEvent
| SessionUsageEvent
| SessionModelEvent
| SessionTitleEvent
| SessionReasoningEffortEvent
| SessionCollaborationModeEvent
| SessionPermissionModeEvent
| SessionAgentChangedEvent
| SessionTodosEvent
| SessionTerminalPendingEvent
@@ -4374,10 +4436,14 @@ class SessionProjectSummary(BaseModel):
:param id: First-class project id when one exists, or ``None`` for a
label-only project not yet promoted to the ``projects`` table.
:param name: Project name (the folder's display name and union key).
:param icon: The project's chosen emoji icon (a unicode grapheme), read
from its ``config``; ``None`` when unset or for a label-only folder,
so the sidebar falls back to the default folder glyph.
"""
id: str | None = None
name: str
icon: str | None = None
class CreateProjectRequest(BaseModel):
+7 -7
View File
@@ -1670,10 +1670,9 @@ def _translate_executor_from_def(
of the supported set so an empty string fails
hard there.
:param raw_executor: Optional raw YAML ``executor:`` mapping.
When present, ``use_responses`` (``bool | None``) is read
from it and forwarded into ``executor.config["use_responses"]``
so the openai-agents harness subprocess reads the correct
API surface (chat/completions vs. responses). The omnigent
When present, OpenAI Agents SDK wire settings are forwarded
into ``executor.config`` so the harness subprocess reads the
correct API surface and reasoning replay policy. The omnigent
loader silently drops unknown fields on its own
:class:`~omnigent.inner.datamodel.ExecutorSpec`, so we
have to recover this field from the raw dict here.
@@ -1749,9 +1748,8 @@ def _translate_executor_from_def(
"harness": harness,
"profile": profile,
}
# ``use_responses`` and ``acp_agent`` are not fields on the omnigent inner
# ExecutorSpec (the loader drops unknown keys), so read them from the raw
# YAML dict and carry them forward explicitly.
# These are not fields on the omnigent inner ExecutorSpec, so read them
# from the raw YAML dict and carry them forward explicitly.
# The openai-agents harness spawn-env builder reads
# ``spec.executor.config["use_responses"]`` to set
# ``HARNESS_OPENAI_AGENTS_USE_RESPONSES``, which controls
@@ -1761,6 +1759,8 @@ def _translate_executor_from_def(
use_responses_raw = raw_executor.get("use_responses")
if use_responses_raw is not None:
config["use_responses"] = bool(use_responses_raw)
if "reasoning_item_id_policy" in raw_executor:
config["reasoning_item_id_policy"] = raw_executor["reasoning_item_id_policy"]
if "acp_agent" in raw_executor:
config["acp_agent"] = raw_executor["acp_agent"]
# ``auth`` is now parsed by the loader into OmniExecutorSpec.auth;
@@ -776,6 +776,7 @@ class ConversationStore(ABC):
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
archived: bool | None = None,
reported_model: str | None = None,
) -> Conversation | None:
"""
Update mutable fields on a conversation.
@@ -785,7 +786,9 @@ class ConversationStore(ABC):
and ``harness_override``,
``None`` means "leave unchanged". To explicitly clear them
back to ``None``, pass
the matching ``_unset_*`` flag.
the matching ``_unset_*`` flag. ``reported_model`` (the model
the harness last reported, verbatim) has no ``_unset`` variant:
reports only ever move forward.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
@@ -118,6 +118,7 @@ class _RowCountResult(Protocol):
_SESSION_OVERRIDE_KEYS = (
"reasoning_effort",
"model_override",
"reported_model",
"cost_control_mode_override",
"subagent_routing_override",
"harness_override",
@@ -209,6 +210,7 @@ def _to_conversation(
session_usage=session_usage,
reasoning_effort=overrides["reasoning_effort"],
model_override=overrides["model_override"],
reported_model=overrides["reported_model"],
cost_control_mode_override=overrides["cost_control_mode_override"],
subagent_routing_override=overrides["subagent_routing_override"],
harness_override=overrides["harness_override"],
@@ -2697,6 +2699,7 @@ class SqlAlchemyConversationStore(ConversationStore):
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
archived: bool | None = None,
reported_model: str | None = None,
) -> Conversation | None:
"""
Update mutable fields on a conversation.
@@ -2708,10 +2711,15 @@ class SqlAlchemyConversationStore(ConversationStore):
e.g. ``"high"``. ``None`` leaves unchanged.
:param _unset_reasoning_effort: When ``True``, clear
``reasoning_effort`` to ``None``.
:param model_override: Per-session LLM model override,
e.g. ``"claude-opus-4-7"``. ``None`` leaves unchanged.
:param model_override: Per-session LLM model override the
user's request, e.g. ``"claude-opus-4-7"``. ``None``
leaves unchanged.
:param _unset_model_override: When ``True``, clear
``model_override`` to ``None``.
:param reported_model: The model the harness last reported the
session is actually on, verbatim, e.g.
``"claude-opus-4-8[1m]"``. ``None`` leaves unchanged.
No ``_unset`` variant reports only ever move forward.
:param cost_control_mode_override: Per-session cost-control
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_cost_control_mode_override: When ``True``, clear
@@ -2764,6 +2772,9 @@ class SqlAlchemyConversationStore(ConversationStore):
elif model_override is not None:
overrides["model_override"] = model_override
overrides_changed = True
if reported_model is not None:
overrides["reported_model"] = reported_model
overrides_changed = True
if _unset_cost_control_mode_override:
overrides["cost_control_mode_override"] = None
overrides_changed = True
@@ -2,6 +2,11 @@
from __future__ import annotations
import collections
import os
import threading
import time
from collections.abc import Callable
from typing import cast
from sqlalchemy import delete, exists, literal, select, update
@@ -26,6 +31,60 @@ from omnigent.stores.permission_store import PermissionStore
# list is identical across auth modes.
_HIDDEN_LIST_USERS = frozenset({RESERVED_USER_PUBLIC, RESERVED_USER_LOCAL})
# Short-lived cache of resolve_access() results. The per-event access-control
# check on a busy session otherwise re-reads session_permissions + users on
# every streamed event, for a session whose grants are stable across the turn.
# Only a *positive* standing is cached — a no-access result is never stored, so
# a freshly granted user is authorized on their next request, not after the TTL.
# This store's own grant/revoke/reassign/set_admin writes evict, and a
# generation counter stops an in-flight reader from re-storing a pre-commit
# positive on top of that eviction, so once such a write returns this instance
# serves no stale decision. Role changes made through the separate accounts
# store (admin demote, user delete) are NOT evicted here and propagate within
# the TTL. Across replicas there is no invalidation broadcast either, so a
# revoke can be up to the TTL late elsewhere; that window is LEVEL_EDIT only —
# the destructive stop/kill path re-gates LEVEL_OWNER separately, and a deleted
# session still 404s on its uncached conversation read. Entries are keyed by
# (conversation_id, user_id):
# conversation ids are globally unique, so eviction needs no workspace context,
# and the map is an LRU bounded by a hard entry cap so a long-lived replica
# cannot grow it without limit (resolve_access is on the snapshot path too, not
# just the hot event path). Set the TTL env to 0 to disable (zero overhead).
_RESOLVE_ACCESS_CACHE_TTL_ENV = "OMNIGENT_ACL_RESOLVE_CACHE_TTL_S"
_DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S = 5.0
_RESOLVE_ACCESS_CACHE_MAX_ENTRIES_ENV = "OMNIGENT_ACL_RESOLVE_CACHE_MAX_ENTRIES"
_DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES = 50_000
def _resolve_access_cache_ttl_s() -> float:
"""Read the resolve_access cache TTL (seconds) from the environment.
Defaults to :data:`_DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S`; a value <= 0
disables the cache. An unparseable value falls back to the default.
"""
raw = os.environ.get(_RESOLVE_ACCESS_CACHE_TTL_ENV)
if raw is None:
return _DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S
try:
return max(float(raw), 0.0)
except ValueError:
return _DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S
def _resolve_access_cache_max_entries() -> int:
"""Read the resolve_access cache entry cap from the environment.
Defaults to :data:`_DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES`; ``0`` means
unbounded. An unparseable value falls back to the default.
"""
raw = os.environ.get(_RESOLVE_ACCESS_CACHE_MAX_ENTRIES_ENV)
if raw is None:
return _DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES
try:
return max(int(raw), 0)
except ValueError:
return _DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES
def _to_account(row: SqlUser) -> Account:
"""Convert a :class:`SqlUser` ORM row to an :class:`Account` entity.
@@ -78,6 +137,27 @@ class SqlAlchemyPermissionStore(PermissionStore):
self._engine,
query_name_prefix="omnigent.permission_store",
)
# resolve_access cache (see _RESOLVE_ACCESS_CACHE_TTL_ENV). An LRU keyed
# (conversation_id, user_id) -> (expiry, access). conversation ids are
# globally unique, so grant/revoke can drop a whole session's entries —
# including the shared __public__ grant, which affects every user of
# that session — without depending on the ambient workspace context.
# The hard entry cap bounds memory on a long-lived replica. Per store
# instance, so each replica caches independently and tests get a fresh
# cache with each store. ``_resolve_cache_clock`` is injectable for
# deterministic TTL tests.
self._resolve_cache_ttl_s = _resolve_access_cache_ttl_s()
self._resolve_cache_max_entries = _resolve_access_cache_max_entries()
self._resolve_cache: collections.OrderedDict[
tuple[str, str], tuple[float, ResolvedAccess]
] = collections.OrderedDict()
self._resolve_cache_lock = threading.Lock()
self._resolve_cache_clock: Callable[[], float] = time.monotonic
# Bumped by every invalidation. resolve_access samples it before its DB
# read and refuses to store a result sampled under an older generation,
# so a reader whose snapshot predates a concurrent grant/revoke commit
# cannot re-poison the cache after that write already evicted.
self._resolve_cache_generation = 0
def grant(
self,
@@ -120,11 +200,13 @@ class SqlAlchemyPermissionStore(PermissionStore):
)
session.execute(stmt)
session.flush()
return SessionPermission(
user_id=user_id,
conversation_id=conversation_id,
level=level,
)
# Evict after commit: the grant changed this session's access picture.
self._invalidate_resolve_cache_for_session(conversation_id)
return SessionPermission(
user_id=user_id,
conversation_id=conversation_id,
level=level,
)
def revoke(self, user_id: str, conversation_id: str) -> bool:
"""Remove a permission grant. See base class for contract."""
@@ -139,7 +221,11 @@ class SqlAlchemyPermissionStore(PermissionStore):
)
),
)
return result.rowcount > 0
deleted = result.rowcount > 0
# Evict after commit: a revoke must not be served stale from this
# instance's cache.
self._invalidate_resolve_cache_for_session(conversation_id)
return deleted
def get(self, user_id: str, conversation_id: str) -> SessionPermission | None:
"""Look up a single grant. See base class for contract."""
@@ -222,7 +308,10 @@ class SqlAlchemyPermissionStore(PermissionStore):
.values(user_id=to_user_id)
)
moved = len(reassign_ids)
return moved
# Grants moved between users across sessions; drop this store's cache
# (the no-rows path above returned early, having changed nothing).
self._invalidate_resolve_cache_all()
return moved
def list_for_session(
self,
@@ -352,6 +441,9 @@ class SqlAlchemyPermissionStore(PermissionStore):
)
.values(is_admin=is_admin)
)
# The admin flag flips access on every session for this user; drop
# this store's cache.
self._invalidate_resolve_cache_all()
def check_access(
self,
@@ -391,6 +483,66 @@ class SqlAlchemyPermissionStore(PermissionStore):
return public_grant.level
return None
def _resolve_cache_lookup(self, conversation_id: str, user_id: str) -> ResolvedAccess | None:
"""Return a live cached resolve_access result, or ``None`` on miss/expiry."""
now = self._resolve_cache_clock()
key = (conversation_id, user_id)
with self._resolve_cache_lock:
entry = self._resolve_cache.get(key)
if entry is None:
return None
expiry, access = entry
if now >= expiry:
del self._resolve_cache[key]
return None
self._resolve_cache.move_to_end(key) # LRU: mark most-recently used
return access
def _resolve_cache_generation_now(self) -> int:
"""Sample the invalidation generation before a read begins."""
with self._resolve_cache_lock:
return self._resolve_cache_generation
def _resolve_cache_store(
self,
conversation_id: str,
user_id: str,
access: ResolvedAccess,
generation: int,
) -> None:
"""Cache one *granted* resolve_access result until now + TTL.
Dropped when *generation* is stale an invalidation landed while this
result was being read, so the value may predate that write and must not
be stored on top of the eviction it already performed. Enforces the LRU
entry cap so the cache cannot grow without bound on a long-lived replica.
"""
key = (conversation_id, user_id)
expiry = self._resolve_cache_clock() + self._resolve_cache_ttl_s
with self._resolve_cache_lock:
if generation != self._resolve_cache_generation:
return
self._resolve_cache[key] = (expiry, access)
self._resolve_cache.move_to_end(key)
max_entries = self._resolve_cache_max_entries
if max_entries > 0:
while len(self._resolve_cache) > max_entries:
self._resolve_cache.popitem(last=False) # drop least-recently used
def _invalidate_resolve_cache_for_session(self, conversation_id: str) -> None:
"""Drop every cached decision for one session (all users + ``__public__``)."""
with self._resolve_cache_lock:
self._resolve_cache_generation += 1
stale = [key for key in self._resolve_cache if key[0] == conversation_id]
for key in stale:
del self._resolve_cache[key]
def _invalidate_resolve_cache_all(self) -> None:
"""Drop the whole cache — for admin-flag or bulk-grant changes."""
with self._resolve_cache_lock:
self._resolve_cache_generation += 1
self._resolve_cache.clear()
def resolve_access(
self,
user_id: str | None,
@@ -403,6 +555,17 @@ class SqlAlchemyPermissionStore(PermissionStore):
user_grant_level=None,
public_grant_level=None,
)
workspace_id = current_workspace_id()
cache_enabled = self._resolve_cache_ttl_s > 0
generation = 0
if cache_enabled:
cached = self._resolve_cache_lookup(conversation_id, user_id)
if cached is not None:
return cached
# Sampled before the read: an invalidation landing while the rows
# below are being fetched makes this result unstorable, so a write
# committed mid-read can't be undone by a stale positive.
generation = self._resolve_cache_generation_now()
# One session = one connection checkout + transaction. Against a
# remote DB (Lakebase) this is the round-trip that matters; the three
# primary-key reads below pipeline on the same connection rather than
@@ -410,19 +573,29 @@ class SqlAlchemyPermissionStore(PermissionStore):
# calling is_admin + check_access + get_permission_level separately
# did — see the GET /v1/sessions/{id} snapshot path).
with self._session("resolve_access") as session:
user_row = session.get(SqlUser, (current_workspace_id(), user_id))
user_row = session.get(SqlUser, (workspace_id, user_id))
user_grant = session.get(
SqlSessionPermission, (current_workspace_id(), user_id, conversation_id)
SqlSessionPermission, (workspace_id, user_id, conversation_id)
)
public_grant = session.get(
SqlSessionPermission,
(current_workspace_id(), RESERVED_USER_PUBLIC, conversation_id),
(workspace_id, RESERVED_USER_PUBLIC, conversation_id),
)
return ResolvedAccess(
access = ResolvedAccess(
is_admin=user_row is not None and user_row.is_admin,
user_grant_level=user_grant.level if user_grant is not None else None,
public_grant_level=public_grant.level if public_grant is not None else None,
)
# Cache only a positive standing: a no-access result is left uncached so
# a freshly granted user is authorized on their next request, not after
# the TTL elapses.
if cache_enabled and (
access.is_admin
or access.user_grant_level is not None
or access.public_grant_level is not None
):
self._resolve_cache_store(conversation_id, user_id, access, generation)
return access
def has_any_grants(self, conversation_id: str) -> bool:
"""Check for any permission rows. See base class for contract."""
@@ -50,6 +50,7 @@ class ScheduledTaskStore(ABC):
*,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = None,
workspace: str | None = None,
host_id: str | None = None,
state: str = "active",
@@ -68,6 +69,7 @@ class ScheduledTaskStore(ABC):
:param timezone: IANA timezone the trigger is evaluated in.
:param model_override: Optional LLM model override.
:param reasoning_effort: Optional reasoning-effort hint.
:param max_cost_usd: Optional per-firing cost budget in USD.
:param workspace: Runner start path (source repo / working dir).
:param host_id: The connected host to pin the run to.
:param state: Lifecycle state ``active``/``paused``/``deleted``.
@@ -131,6 +133,7 @@ class ScheduledTaskStore(ABC):
timezone: str | None = None,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = _UNSET,
workspace: str | None = None,
host_id: str | None = _UNSET,
state: str | None = None,
@@ -53,6 +53,7 @@ def _to_entity(row: SqlScheduledTask) -> ScheduledTask:
rrule=row.rrule,
model_override=row.model_override,
reasoning_effort=row.reasoning_effort,
max_cost_usd=row.max_cost_usd,
workspace=row.workspace,
base_branch=row.base_branch,
execution_target=decode_scheduled_task_execution_target(row.execution_target),
@@ -127,6 +128,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
*,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = None,
workspace: str | None = None,
host_id: str | None = None,
state: str = "active",
@@ -142,6 +144,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
timezone=timezone,
model_override=model_override,
reasoning_effort=reasoning_effort,
max_cost_usd=max_cost_usd,
workspace=workspace,
base_branch=None,
execution_target=encode_scheduled_task_execution_target("connected_host"),
@@ -246,6 +249,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
timezone: str | None = None,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = _UNSET,
workspace: str | None = None,
host_id: str | None = _UNSET,
state: str | None = None,
@@ -254,11 +258,11 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
) -> ScheduledTask | None:
"""Update mutable fields.
``None`` leaves most fields unchanged. For ``host_id`` and
``last_run_conversation_id``, the sentinel default means "not provided
/ leave unchanged"; passing ``None`` explicitly sets the column to NULL.
Passing ``rrule`` updates the recurring trigger; ``None``
leaves it unchanged.
``None`` leaves most fields unchanged. For ``host_id``,
``max_cost_usd``, and ``last_run_conversation_id``, the sentinel
default means "not provided / leave unchanged"; passing ``None``
explicitly sets the column to NULL. Passing ``rrule`` updates the
recurring trigger; ``None`` leaves it unchanged.
"""
with self._session("update_task") as session:
row = session.get(SqlScheduledTask, (current_workspace_id(), scheduled_task_id))
@@ -283,6 +287,9 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
if reasoning_effort is not None and row.reasoning_effort != reasoning_effort:
row.reasoning_effort = reasoning_effort
changed = True
if max_cost_usd is not _UNSET and row.max_cost_usd != max_cost_usd:
row.max_cost_usd = max_cost_usd
changed = True
if workspace is not None and row.workspace != workspace:
row.workspace = workspace
changed = True
+109
View File
@@ -0,0 +1,109 @@
"""Detect a system suspend/resume (laptop sleep) so live connections can
reconnect promptly instead of waiting out the WebSocket keepalive.
When a laptop's lid closes the OS freezes the whole process and drops the
network. Long-lived WebSockets (the host control channel in
:mod:`omnigent.host.connect`, every runner tunnel in
:mod:`omnigent.runner.transports.ws_tunnel.serve`) become half-open sockets
the peer has already dropped. Nothing notices until the ``websockets``
keepalive ping times out up to ~120 s (``ping_interval`` 30 s +
``ping_timeout`` 90 s) during which the host and its sessions look offline
to the server and the desktop app.
:func:`watch_for_resume` gives an event loop a cheap way to react the instant
it wakes: it polls a short interval and compares how far the realtime (wall)
clock advanced against the monotonic clock. The monotonic clock freezes while
the machine is asleep (macOS ``mach_absolute_time`` and Linux
``CLOCK_MONOTONIC`` both exclude sleep) while the realtime clock keeps
counting, so a resume shows up as a large divergence between the two across a
single poll. A merely-blocked event loop (a long synchronous call, a GC pause)
advances *both* clocks equally, so the divergence stays ~0 this never
false-fires on CPU stalls, only on a real suspend.
Deliberately uses :func:`time.monotonic`, never the event loop's
``loop.time()``: uvloop's ``loop.time()`` is backed by libuv's clock, which
*includes* sleep on macOS (see the same trap documented in
``omnigent/runtime/harnesses/process_manager.py``), which would zero out the
divergence and silently disable detection. :func:`time.monotonic` is
process-wide and loop-independent.
Windows note: ``time.monotonic()`` on Windows counts suspended time, so the
divergence stays ~0 there and this watcher never fires the connection falls
back to the keepalive-timeout behavior it has today (no regression).
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
_logger = logging.getLogger(__name__)
# Poll cadence. Detection latency after a wake is at most one interval: the
# in-flight sleep's deadline is on the frozen monotonic clock, so it elapses
# shortly after the machine resumes. 5 s keeps wake reconnects snappy at
# negligible cost — the host already runs a 2 s orphan-reaper and a 5 s
# harness-readiness loop, so this adds no meaningful wakeups.
SUSPEND_POLL_INTERVAL_S = 5.0
# Minimum wall-minus-monotonic divergence, in seconds, that counts as a resume.
# Comfortably above scheduler jitter and clock-read skew. Because the signal is
# the *divergence* (not raw wall time), a blocked event loop can never reach it
# no matter how long it blocks — only real suspended time diverges the clocks.
# A lid-close shorter than this won't fire, but such a short sleep rarely drops
# the socket (the keepalive budget is 90 s) so the connection stays healthy.
SUSPEND_GAP_THRESHOLD_S = 15.0
async def watch_for_resume(
on_resume: Callable[[float], None],
*,
interval_s: float = SUSPEND_POLL_INTERVAL_S,
threshold_s: float = SUSPEND_GAP_THRESHOLD_S,
wall_clock: Callable[[], float] = time.time,
mono_clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
"""Call ``on_resume(gap_s)`` once each time the machine resumes from sleep.
Loops forever polling ``interval_s`` at a time; whenever the wall clock
advanced more than ``threshold_s`` beyond the monotonic clock across one
poll, the machine slept and just woke, and ``on_resume`` is invoked with
the approximate sleep duration in seconds. Cancel the task to stop.
``on_resume`` runs on the event loop and must be sync, non-blocking, and
must not raise callers use it to abort a now-dead socket and flag a
prompt reconnect. Any exception it raises is logged and swallowed so the
watcher survives.
:param on_resume: Sync callback invoked once per detected resume with the
approximate seconds spent asleep.
:param interval_s: Poll cadence; also the worst-case detection latency
after a wake.
:param threshold_s: Minimum wall-minus-monotonic divergence that counts as
a resume.
:param wall_clock: Realtime clock reader (advances during sleep).
Injectable for tests.
:param mono_clock: Monotonic clock reader (freezes during sleep on
macOS/Linux). Injectable for tests. Defaults to :func:`time.monotonic`
never ``loop.time()`` (see the module docstring).
:param sleep: Awaitable sleeper; injectable so tests can drive the loop
deterministically without patching :func:`asyncio.sleep` globally.
:returns: Never returns normally; cancel the task to stop it.
"""
while True:
wall_before = wall_clock()
mono_before = mono_clock()
await sleep(interval_s)
# Re-sample per iteration (not against a fixed baseline): a resume must
# fire exactly once, on the poll that spanned the sleep. The next poll
# sees gap ~0 again.
gap = (wall_clock() - wall_before) - (mono_clock() - mono_before)
if gap >= threshold_s:
_logger.info("Resumed from suspend (~%.0fs asleep); notifying", gap)
try:
on_resume(gap)
except Exception:
_logger.exception("suspend on_resume callback failed")
+170 -9
View File
@@ -28,10 +28,11 @@ Design notes learned from the protocol (see ``control_bridge`` spike):
the client exits; the hex channel is byte-exact for ESC sequences, control
chars, and UTF-8 multibyte alike.
The browser-facing wire protocol is identical to the PTY bridge (binary frames
out = raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary
frames in = input bytes), so the two transports are interchangeable behind the
same ``/attach`` WebSocket and a client cannot tell which one served it.
The browser-facing terminal stream matches the PTY bridge (binary frames out =
raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary frames
in = input bytes). Control mode additionally sends a typed text JSON frame when
tmux reports a copied paste buffer, because its outer-client OSC 52 is not part
of ``%output``. Both remain interchangeable behind the same ``/attach`` URL.
Known limitation vs the PTY bridge: tmux's own overlays (``display-popup``,
copy-mode, status line) are NOT delivered to a control-mode client, so the
@@ -47,6 +48,7 @@ attach transport, so they behave identically under either bridge.
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import logging
@@ -117,6 +119,21 @@ _CONTROL_STDOUT_BUFFER_LIMIT: Final[int] = 16 * 1024 * 1024
# stuck-slow client can't hang the close; a normal drain completes well within.
_FORWARD_DRAIN_TIMEOUT_S: Final[float] = 5.0
# tmux emits this control notification after copy-mode stores a selection in a
# paste buffer. Only default-style, shell-safe names are accepted; copy-mode's
# generated names (for example ``buffer0``) are covered without letting an
# untrusted protocol line select an arbitrary command target.
_CLIPBOARD_BUFFER_CHANGED_PREFIX: Final = b"%paste-buffer-changed "
_CLIPBOARD_BUFFER_NAME_RE: Final = re.compile(rb"[A-Za-z0-9_.:-]{1,128}\Z")
# Browser clipboard writes should stay text-sized. Bound the raw buffer before
# base64/JSON expansion so a huge tmux buffer cannot become a websocket DoS.
_CLIPBOARD_MAX_BYTES: Final[int] = 1024 * 1024
_CLIPBOARD_READ_TIMEOUT_S: Final[float] = 2.0
# A copy-mode commit follows the initiating key or mouse release immediately.
# Correlating the notification with this client's recent input prevents one
# attached browser from overwriting every other viewer's local clipboard.
_CLIPBOARD_RECENT_INPUT_WINDOW_S: Final[float] = 5.0
def unescape_control_output(value: bytes) -> bytes:
"""Un-escape a ``%output`` value back to raw pane bytes.
@@ -133,6 +150,79 @@ def unescape_control_output(value: bytes) -> bytes:
return _OCTAL_ESCAPE_RE.sub(lambda m: bytes([int(m.group(1), 8)]), value)
async def _read_tmux_buffer(
tmux: str,
socket_path: str,
buffer_name: str,
) -> bytes | None:
"""Read one named tmux buffer exactly, rejecting failures and oversized data.
``save-buffer ... -`` writes the raw bytes without ``show-buffer``'s display
formatting. ``readexactly(limit + 1)`` distinguishes an in-range buffer
(EOF with a partial result) from an oversized one without first buffering
an unbounded subprocess result in Python.
:param tmux: Absolute tmux executable path.
:param socket_path: Private tmux server socket.
:param buffer_name: Validated tmux buffer name, e.g. ``"buffer0"``.
:returns: Raw buffer bytes, or ``None`` when unavailable/oversized.
"""
try:
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
socket_path,
"save-buffer",
"-b",
buffer_name,
"-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except (OSError, ValueError):
return None
assert proc.stdout is not None
try:
try:
data = await asyncio.wait_for(
proc.stdout.readexactly(_CLIPBOARD_MAX_BYTES + 1),
timeout=_CLIPBOARD_READ_TIMEOUT_S,
)
oversized = True
except asyncio.IncompleteReadError as exc:
data = exc.partial
oversized = False
if oversized:
with contextlib.suppress(ProcessLookupError):
proc.kill()
await asyncio.wait_for(proc.wait(), timeout=_CLIPBOARD_READ_TIMEOUT_S)
except asyncio.CancelledError:
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await asyncio.shield(proc.wait())
raise
except (asyncio.TimeoutError, OSError):
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
return None
if oversized or proc.returncode != 0:
return None
return data
def _clipboard_buffer_name(line: bytes) -> str | None:
"""Extract a safe buffer name from a tmux clipboard notification."""
if not line.startswith(_CLIPBOARD_BUFFER_CHANGED_PREFIX):
return None
raw_name = line[len(_CLIPBOARD_BUFFER_CHANGED_PREFIX) :]
if _CLIPBOARD_BUFFER_NAME_RE.fullmatch(raw_name) is None:
return None
return raw_name.decode("ascii")
def _hex_send_keys_commands(target: str, data: bytes) -> list[bytes]:
"""Build ``send-keys -H`` control-mode command line(s) for raw input bytes.
@@ -404,7 +494,8 @@ async def bridge_tmux_control_to_websocket(
Drop-in alternative to
:func:`omnigent.terminals.ws_bridge.bridge_tmux_pty_to_websocket` with the
same signature and browser wire protocol. Caller must have called
same signature and terminal byte stream. Control mode additionally emits
server-to-browser clipboard JSON frames. Caller must have called
``websocket.accept()``. On exit (any branch) the control client is torn
down and the websocket closed best-effort with the shared 4404/4405 codes.
@@ -476,9 +567,16 @@ async def bridge_tmux_control_to_websocket(
# one bounded ``send_bytes``, so when the browser send lags tmux's firehose
# a backlog of tiny per-line payloads collapses into a few large frames.
output_chunks: asyncio.Queue[bytes | None] = asyncio.Queue()
# Clipboard notifications are handled outside the raw output hot path: each
# item names the immutable tmux buffer created by copy-mode; None is EOF.
clipboard_buffers: asyncio.Queue[str | None] = asyncio.Queue()
# Terminal bytes and clipboard JSON have separate producer tasks but one
# websocket. Serialize sends so ASGI never sees concurrent send calls.
ws_send_lock = asyncio.Lock()
# Monotonic stamp of the last forwarded browser input; the forwarder reads
# it to shrink the frame cap right after a keystroke (keeps the echo on
# xterm's synchronous paint path — see the PTY bridge).
# xterm's synchronous paint path — see the PTY bridge) and clipboard
# forwarding uses it to identify which attached client initiated a copy.
last_client_input_at: float | None = None
def _current_ws_coalesce_limit() -> int:
@@ -512,6 +610,15 @@ async def bridge_tmux_control_to_websocket(
if len(parts) == 3:
output_chunks.put_nowait(unescape_control_output(parts[2]))
return True
buffer_name = _clipboard_buffer_name(line)
if buffer_name is not None:
if (
not read_only
and last_client_input_at is not None
and _monotonic() - last_client_input_at <= _CLIPBOARD_RECENT_INPUT_WINDOW_S
):
clipboard_buffers.put_nowait(buffer_name)
return True
if line.startswith(b"%exit"):
return False
if line.startswith(b"%window-close"):
@@ -549,9 +656,49 @@ async def bridge_tmux_control_to_websocket(
return
finally:
output_chunks.put_nowait(None)
clipboard_buffers.put_nowait(None)
if reader_done is not None:
reader_done.set()
async def _forward_clipboard_updates() -> None:
"""Read copied tmux buffers and send bounded clipboard control frames."""
while True:
buffer_name = await clipboard_buffers.get()
if buffer_name is None:
return
# When several copies arrive before the subprocess starts, only the
# newest clipboard value matters. Preserve an EOF sentinel so the
# task exits after forwarding that final value.
eof_seen = False
while True:
try:
next_name = clipboard_buffers.get_nowait()
except asyncio.QueueEmpty:
break
if next_name is None:
eof_seen = True
break
buffer_name = next_name
data = await _read_tmux_buffer(tmux, socket_path, buffer_name)
if data is not None:
message = json.dumps(
{
"type": "clipboard-write",
"encoding": "base64",
"data": base64.b64encode(data).decode("ascii"),
},
separators=(",", ":"),
)
try:
async with ws_send_lock:
await websocket.send_text(message)
except (RuntimeError, WebSocketDisconnect):
return
if eof_seen:
return
async def _ws_to_control() -> None:
"""Read browser frames; resize via refresh-client -C, input via -H hex."""
nonlocal last_client_input_at
@@ -600,10 +747,16 @@ async def bridge_tmux_control_to_websocket(
read_task = asyncio.create_task(_read_control(), name="tmux-control-read")
forward_task = asyncio.create_task(
_forward_pty_to_ws(
websocket, output_chunks, max_coalesce_bytes=_current_ws_coalesce_limit
websocket,
output_chunks,
max_coalesce_bytes=_current_ws_coalesce_limit,
send_lock=ws_send_lock,
),
name="tmux-control-forward",
)
clipboard_task = asyncio.create_task(
_forward_clipboard_updates(), name="tmux-control-clipboard"
)
if forward_done is not None:
forward_task.add_done_callback(lambda _task: forward_done.set())
ws_task = asyncio.create_task(_ws_to_control(), name="tmux-ws-to-control")
@@ -612,6 +765,9 @@ async def bridge_tmux_control_to_websocket(
# finishing is downstream (it drains, then sees the EOF sentinel).
control_ended_first = False
try:
# The clipboard task is intentionally not a FIRST_COMPLETED trigger: it
# may finish after the reader's EOF sentinel, but the reader itself is
# the authoritative control-side completion signal.
done, pending = await asyncio.wait(
{read_task, forward_task, ws_task}, return_when=asyncio.FIRST_COMPLETED
)
@@ -636,13 +792,18 @@ async def bridge_tmux_control_to_websocket(
await asyncio.wait_for(
asyncio.shield(forward_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
)
for task in pending:
if control_ended_first and not clipboard_task.done():
with contextlib.suppress(Exception):
await asyncio.wait_for(
asyncio.shield(clipboard_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
)
for task in {*pending, clipboard_task}:
if task.done():
continue
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
for task in {read_task, forward_task, ws_task}:
for task in {read_task, forward_task, clipboard_task, ws_task}:
if task.done() and not task.cancelled():
exc = task.exception()
if exc is not None:
+32 -2
View File
@@ -16,7 +16,9 @@ Used by:
Wire protocol (same as the original server route):
- **Server client**: every PTY read becomes a *binary* WS frame.
- **Server client**: every PTY read becomes a *binary* WS frame. Production
attaches first send a text OSC 52 capability frame so the browser can reject
clipboard escapes when tmux passthrough weakens the normal trust boundary.
- **Client server**:
- **Text frames** are JSON control messages. Currently only
``{"type": "resize", "cols": N, "rows": M}`` (applied via
@@ -417,6 +419,7 @@ async def _forward_pty_to_ws(
pty_chunks: asyncio.Queue[bytes | None],
*,
max_coalesce_bytes: int | Callable[[], int] = _WS_COALESCE_MAX_BYTES,
send_lock: asyncio.Lock | None = None,
) -> None:
"""
Forward queued PTY output to *websocket*, coalescing ready chunks.
@@ -440,6 +443,9 @@ async def _forward_pty_to_ws(
:data:`_WS_COALESCE_MAX_BYTES`; ``bridge_tmux_pty_to_websocket``
supplies a callable so recently-typed redraws can use the smaller
interactive cap while normal output keeps the larger flood cap.
:param send_lock: Optional lock shared with another server-to-browser
sender. Control mode uses it to serialize terminal bytes with clipboard
control frames; PTY mode has only this sender and leaves it unset.
:returns: None on EOF or websocket disconnect.
"""
pending = bytearray()
@@ -468,7 +474,11 @@ async def _forward_pty_to_ws(
frame = bytes(pending[:limit])
del pending[:limit]
try:
await websocket.send_bytes(frame)
if send_lock is None:
await websocket.send_bytes(frame)
else:
async with send_lock:
await websocket.send_bytes(frame)
except (RuntimeError, WebSocketDisconnect):
return
if eof_seen:
@@ -517,6 +527,7 @@ async def bridge_tmux_pty_to_websocket(
tmux_target: str,
read_only: bool,
on_client_interaction: Callable[[], None] | None = None,
allow_osc52_clipboard: bool | None = None,
) -> None:
"""
Bridge a tmux attach PTY to an already-accepted *websocket*.
@@ -545,12 +556,31 @@ async def bridge_tmux_pty_to_websocket(
mis-reading them as agent activity. ``None`` (e.g. the
server-direct attach path, which is out-of-process from the
watcher) disables that attribution.
:param allow_osc52_clipboard: Whether the browser may honor OSC 52 from this
PTY. Production passes ``False`` when tmux passthrough is enabled, since
pane output could otherwise bypass ``set-clipboard external``. ``None``
omits the capability frame for low-level test compatibility.
"""
# Attaching is itself a client interaction: tmux resizes the window to
# the new client, which reflows the pane. Stamp it before the bridge
# starts so that reflow is discounted.
if on_client_interaction is not None:
on_client_interaction()
if allow_osc52_clipboard is not None:
try:
await websocket.send_text(
json.dumps(
{
"type": "osc52-clipboard-capability",
"enabled": allow_osc52_clipboard and not read_only,
},
separators=(",", ":"),
)
)
except (RuntimeError, WebSocketDisconnect):
return
argv = ["tmux", "-S", socket_path, "attach"]
if read_only:
argv.append("-r")
+26 -5
View File
@@ -188,8 +188,8 @@ class SysAgentListTool(Tool):
"""
List launchable agents across three sources.
A **global read** that surfaces, in one call, every agent the caller
could launch a session from:
A **global read** that pages agents the caller could launch a session
from across three sources:
- **built-ins**: template agents registered on the server
(``GET /v1/agents``);
@@ -230,7 +230,10 @@ class SysAgentListTool(Tool):
"agent never needs its bundle downloaded or re-uploaded. "
"Use sys_agent_get / sys_agent_download (with a "
"session_agents row's session_id) only to inspect or fork "
"an agent's config. Global read — no parameters."
"an agent's config. Calls without pagination keep the complete "
"result while it fits the tool-output budget; larger results "
"return a page with has_more metadata and an opaque "
"next_cursor. Pass that cursor to continue."
)
def get_schema(self) -> dict[str, Any]:
@@ -238,7 +241,7 @@ class SysAgentListTool(Tool):
Return the OpenAI-format tool schema.
:returns: Dict with ``"type": "function"`` and a
``"function"`` sub-dict; no parameters.
``"function"`` sub-dict; optional pagination parameters.
"""
return {
"type": "function",
@@ -247,7 +250,25 @@ class SysAgentListTool(Tool):
"description": SysAgentListTool.description(),
"parameters": {
"type": "object",
"properties": {},
"properties": {
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": (
"Optional maximum rows returned from each source. "
"Omit it to keep the complete result while it fits."
),
},
"cursor": {
"type": "string",
"maxLength": 40000,
"description": (
"Opaque continuation cursor from a prior "
"sys_agent_list result's page.next_cursor."
),
},
},
"additionalProperties": False,
},
},
@@ -93,6 +93,14 @@ class SysScheduledTaskCreateTool(Tool):
"type": "string",
"description": "Optional per-run reasoning-effort hint, e.g. 'high'.",
},
"max_cost_usd": {
"type": "number",
"description": (
"Optional per-firing cost budget in USD. When set, each "
"fired session is capped at this spend — all models are "
"blocked once the limit is reached. Omit for no cap."
),
},
"workspace": {
"type": "string",
"description": (
@@ -188,6 +196,12 @@ class SysScheduledTaskUpdateTool(Tool):
"type": "string",
"description": "New reasoning-effort hint.",
},
"max_cost_usd": {
"type": "number",
"description": (
"New per-firing cost budget in USD. Null clears the cap."
),
},
"workspace": {
"type": "string",
"description": "New existing absolute runner start path.",
+24 -2
View File
@@ -499,7 +499,11 @@ class SysSessionListTool(Tool):
"for orchestration (inspect via sys_agent_get / "
"sys_session_get_info, or drive via sys_session_send by "
"session_id). Pass agent_name to filter the global list to "
"sessions running that agent."
"sessions running that agent. Calls without pagination keep "
"the complete result while it fits the tool-output budget; "
"larger global session lists return a page with has_more "
"metadata and an opaque next_cursor. Pass that cursor to continue; sub_agents stays "
"complete."
)
def get_schema(self) -> dict[str, Any]:
@@ -507,7 +511,8 @@ class SysSessionListTool(Tool):
Return the OpenAI-format tool schema.
:returns: Dict with ``"type": "function"`` and a
``"function"`` sub-dict; an optional ``agent_name`` filter.
``"function"`` sub-dict; an optional ``agent_name`` filter
and pagination parameters.
"""
return {
"type": "function",
@@ -526,6 +531,23 @@ class SysSessionListTool(Tool):
"affect the 'sub_agents' view."
),
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": (
"Optional maximum rows returned from 'sessions'. "
"Omit it to keep the complete result while it fits."
),
},
"cursor": {
"type": "string",
"maxLength": 40000,
"description": (
"Opaque continuation cursor from a prior "
"sys_session_list result's page.next_cursor."
),
},
},
"required": [],
"additionalProperties": False,
+130 -7
View File
@@ -3695,6 +3695,7 @@
"session.mcp_startup": "#/components/schemas/SessionMcpStartupEvent",
"session.model": "#/components/schemas/SessionModelEvent",
"session.model_options": "#/components/schemas/SessionModelOptionsEvent",
"session.permission_mode": "#/components/schemas/SessionPermissionModeEvent",
"session.presence": "#/components/schemas/SessionPresenceEvent",
"session.reasoning_effort": "#/components/schemas/SessionReasoningEffortEvent",
"session.resource.created": "#/components/schemas/SessionResourceCreatedEvent",
@@ -3705,6 +3706,7 @@
"session.superseded": "#/components/schemas/SessionSupersededEvent",
"session.terminal.activity": "#/components/schemas/SessionTerminalActivityEvent",
"session.terminal_pending": "#/components/schemas/SessionTerminalPendingEvent",
"session.title": "#/components/schemas/SessionTitleEvent",
"session.todos": "#/components/schemas/SessionTodosEvent",
"session.usage": "#/components/schemas/SessionUsageEvent",
"turn.cancelled": "#/components/schemas/TurnCancelledEvent",
@@ -3724,12 +3726,18 @@
{
"$ref": "#/components/schemas/SessionModelEvent"
},
{
"$ref": "#/components/schemas/SessionTitleEvent"
},
{
"$ref": "#/components/schemas/SessionReasoningEffortEvent"
},
{
"$ref": "#/components/schemas/SessionCollaborationModeEvent"
},
{
"$ref": "#/components/schemas/SessionPermissionModeEvent"
},
{
"$ref": "#/components/schemas/SessionAgentChangedEvent"
},
@@ -4771,7 +4779,7 @@
"type": "object"
},
"SessionModelEvent": {
"description": "Active-model update from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from the\n`omnigent claude` transcript forwarder when the model is\nswitched inside the Claude Code terminal (a `/model` command or\nthe in-TUI picker). Lets the web model picker reflect a TUI-side\nswitch without a reload.",
"description": "Active-model report from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from a native\nforwarder \u2014 the launch's own model report, or a switch made inside\nthe pane (a `/model` command or the in-TUI picker). Every surface\nre-renders its model display from this.",
"properties": {
"conversation_id": {
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
@@ -4779,7 +4787,7 @@
"type": "string"
},
"model": {
"description": "Tier alias the session is now on, e.g. `\"opus\"` \u2014 Claude Code's version-agnostic alias, matching the picker's vocabulary (not a pinned `\"claude-opus-4-8\"` id). Category: **transient** (SSE-only). The server also writes `model_override` on the conversation, so on reconnect clients restore the selection from the snapshot's `model_override` rather than from a replayed event.",
"description": "The model the harness reports the session is on, VERBATIM in the harness's own spelling, e.g. `\"claude-opus-4-8[1m]\"` or `\"gpt-5.6-luna\"` \u2014 never collapsed to a picker alias. Category: **transient** (SSE-only). The server also writes `reported_model` on the conversation (served on the snapshot's `llm_model`), so on reconnect clients restore the display from the snapshot rather than from a replayed event.",
"title": "Model",
"type": "string"
},
@@ -4844,6 +4852,46 @@
"title": "SessionModelOptionsEvent",
"type": "object"
},
"SessionPermissionModeEvent": {
"description": "Active permission-mode update from a claude-native session.\n\nEmitted after the web UI switches the mode, and after the Claude forwarder\nobserves a different mode in the pane footer \u2014 a shift+tab pressed inside\nthe TUI, which Omnigent has no other way to see. Lets the composer's mode\npicker track the pane without a reload.",
"properties": {
"conversation_id": {
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
"title": "Conversation Id",
"type": "string"
},
"permission_mode": {
"description": "The active mode, e.g. `\"auto\"` or `\"plan\"`. Category: **transient** (SSE-only). The server also writes `omnigent.claude_native.permission_mode` on the conversation labels, so reconnecting clients restore the same state from the session snapshot.",
"title": "Permission Mode",
"type": "string"
},
"sequence_number": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Sequence Number"
},
"type": {
"const": "session.permission_mode",
"description": "Always `\"session.permission_mode\"`.",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"conversation_id",
"permission_mode"
],
"title": "SessionPermissionModeEvent",
"type": "object"
},
"SessionPresenceEvent": {
"description": "The session's viewer list changed \u2014 full state, not a delta.\n\nEmitted on `GET /v1/sessions/{id}/stream` whenever a user\njoins, leaves (after the server-side grace window absorbs\nreconnect churn), or flips their idle aggregate, and once to\neach newly-connected stream as a snapshot-on-connect. Every\nevent carries the COMPLETE viewer list so clients replace their\nstate wholesale \u2014 missed events self-heal on the next event or\nreconnect. Viewers are scoped to the session *tree* (the root\nconversation and every sub-agent conversation under it), so a\nuser on a sub-agent page and a user on the root page appear in\neach other's lists. See `omnigent/server/presence.py` and\n`designs/UI/PRESENCE.md`.",
"properties": {
@@ -4890,6 +4938,18 @@
"SessionProjectSummary": {
"description": "One entry of `GET /v1/sessions/projects` \u2014 a sidebar project folder.\n\nDual-read union of first-class projects and legacy `omni_project`\nlabel-projects, keyed by name.",
"properties": {
"icon": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The project's chosen emoji icon (a unicode grapheme), read from its `config`; `None` when unset or for a label-only folder, so the sidebar falls back to the default folder glyph.",
"title": "Icon"
},
"id": {
"anyOf": [
{
@@ -5360,7 +5420,7 @@
"type": "null"
}
],
"description": "The LLM model identifier from the bound agent's spec, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when the agent has no explicit `llm:` block or the agent cannot be looked up.",
"description": "The model this session is actually on. When the harness has reported one (`reported_model`, written by `external_model_change`), that verbatim value serves here and is the only model value clients display; otherwise the bound agent spec's model, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when neither exists.",
"title": "Llm Model"
},
"mcp_startup": {
@@ -5990,6 +6050,46 @@
"title": "SessionTerminalPendingEvent",
"type": "object"
},
"SessionTitleEvent": {
"description": "Session-title update from a terminal-backed integration.\n\nEmitted after an `external_session_title` POST from the\n`omnigent claude` transcript forwarder when the operator renames\nthe session inside the Claude Code pane (`/rename`). Lets the web\nsession list show the new name without a reload.",
"properties": {
"conversation_id": {
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
"title": "Conversation Id",
"type": "string"
},
"sequence_number": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Sequence Number"
},
"title": {
"description": "Title the session is now on, e.g. `\"auth-refactor\"`. Category: **transient** (SSE-only). The server also writes `title` on the conversation, so on reconnect clients restore the name from the session snapshot rather than from a replayed event.",
"title": "Title",
"type": "string"
},
"type": {
"const": "session.title",
"description": "Always `\"session.title\"`.",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"conversation_id",
"title"
],
"title": "SessionTitleEvent",
"type": "object"
},
"SessionTodosEvent": {
"description": "Todo-list update from a Claude Code terminal-backed session.\n\nEmitted after an `external_session_todos` POST from the\n`omnigent claude` transcript forwarder, which captures todo\nupdates via `PostToolUse`/`TodoWrite` hook events from Claude\nCode and forwards them to the Omnigent server. Lets web render a\nlive todo panel in the right column without polling.",
"properties": {
@@ -6094,6 +6194,20 @@
"title": "Models",
"type": "object"
},
"other_harnesses": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Other Harnesses"
},
"title": {
"anyOf": [
{
@@ -6935,6 +7049,18 @@
"description": "Per-session LLM model override, e.g. `\"claude-opus-4-7\"`. The value is forwarded as-is to the executor at turn start; the server does not enumerate valid models. Clear aliases such as `\"default\"`, `\"off\"`, or `\"reset\"` remove the override (matching the REPL's `/model` semantics). `None` leaves unchanged.",
"title": "Model Override"
},
"permission_mode": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Claude-native permission mode to switch a running session to, e.g. `\"auto\"`. Only the modes Claude Code's shift+tab cycle can reach are accepted (`default`, `acceptEdits`, `plan`, `auto`) \u2014 `dontAsk` and `bypassPermissions` are launch-only. Only valid for sessions stamped with the claude-native wrapper label. Unlike the other fields here the switch is applied by the live TUI, so a failure to reach the mode is surfaced as an error rather than persisted. Omitted leaves unchanged.",
"title": "Permission Mode"
},
"project_id": {
"anyOf": [
{
@@ -8137,10 +8263,7 @@
"application/json": {
"schema": {
"additionalProperties": {
"items": {
"additionalProperties": true,
"type": "object"
},
"items": {},
"type": "array"
},
"title": "Response Get Host Model Options V1 Hosts Host Id Harnesses Harness Model Options Get",
+10
View File
@@ -112,6 +112,12 @@ importers:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
'@emoji-mart/react':
specifier: ^1.1.1
version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1)
'@fontsource-variable/geist-mono':
specifier: ^5.2.7
version: 5.3.0
@@ -235,6 +241,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
katex:
specifier: ^0.16.47
version: 0.16.47
@@ -4122,6 +4131,7 @@ packages:
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
+1 -6
View File
@@ -246,12 +246,6 @@ cursor = ["cursor-sdk>=0.1.7"]
# _reflect). The tools import the client lazily, so only users who enable a
# Hindsight memory tool need this extra.
hindsight = ["hindsight-client>=0.4.0"]
# Backwards-compatibility alias: this extra was renamed to `hindsight` (see
# above) in #2605. Keep `memory` pulling the same client so existing
# `omnigent[memory]` / `--extra memory` invocations keep working.
# TODO(0.70): remove this `memory` alias extra — it exists only to keep the
# pre-rename install command working during the deprecation window.
memory = ["hindsight-client>=0.4.0"]
# Nimble research runs (the `nimble_research` builtin). The tool imports the
# nimble-python client lazily, so only users who enable nimble_research need
# this extra. nimble_extract talks raw httpx and needs no extra.
@@ -489,6 +483,7 @@ markers = [
"mock_only: tests/integration test that only works in mock-LLM mode (no --llm-api-key). Skipped by tests/integration/conftest.py when a real --llm-api-key is supplied (the real-LLM Integration jobs). Use for tests whose mock LLM is scripted with a fixed tool-call sequence — a real LLM cannot reproduce the scripted markers.",
"visual: UI diff visual-regression snapshot (pytest-playwright-visual-snapshot). Runs only in the pinned-runner gate (.github/workflows/ui-snapshot.yml); the main e2e_ui suite excludes it via -m 'not visual' since it runs on the unpinned ubuntu-latest.",
"smart_routing: end-to-end Smart Routing CUJ (tests/e2e/routing). Opt-in via OMNIGENT_E2E_SMART_ROUTING=1: launches a real omnigent host plus real claude/codex TUIs against a gateway. The routing service itself is an in-test mock, so no AI-Gateway task_v1 deployment is needed.",
"live_model_flows: end-to-end model-flow CUJs (tests/e2e/omnigent/test_model_flows_live.py). Opt-in via OMNIGENT_E2E_MODEL_FLOWS=1: boots a real omnigent server + host from a checkout (OMNIGENT_E2E_MODEL_FLOWS_REPO overrides which one, enabling the red-on-main matrix) with real claude/codex logins, drives the real SPA in a browser, and asserts pane truth via tmux. Never runs in CI by accident; each landing PR runs it locally per docs/model-flows test plan.",
"posix_only: test relies on POSIX-only behaviour (fork, PTY, tmux, Unix sockets, signals); auto-skipped on Windows by tests/conftest.py.",
"windows_only: test relies on Windows-only behaviour (Job Objects, cmd.exe); auto-skipped on POSIX by tests/conftest.py.",
]
+93 -1
View File
@@ -120,6 +120,39 @@ def _restore_logging_state() -> Iterator[None]:
logger.propagate = propagate
def test_global_profiling_writes_summary_and_timestamped_stats(tmp_path: Path) -> None:
"""The real entry point profiles any command selected after the root flag."""
repo_root = Path(__file__).resolve().parents[2]
pythonpath = os.pathsep.join(
part for part in (str(repo_root), os.environ.get("PYTHONPATH")) if part
)
result = subprocess.run(
[sys.executable, "-m", "omnigent", "--profiling", "version"],
capture_output=True,
text=True,
timeout=30,
cwd=tmp_path,
env={
**os.environ,
"PYTHONPATH": pythonpath,
"OMNIGENT_DATA_DIR": str(tmp_path / "data"),
},
)
assert result.returncode == 0, result.stderr
assert "CLI profile:" in result.stderr
assert "Top Omnigent call paths" in result.stderr
assert "Top Omnigent functions by self time" in result.stderr
self_time_table = result.stderr.split("Top Omnigent functions by self time", 1)[1]
assert "<built-in method" not in self_time_table
assert "Full profile data:" in result.stderr
[profile_path] = list((tmp_path / "data" / "profiles").glob("omnigent-cli-*.prof"))
import pstats
assert pstats.Stats(str(profile_path)).total_calls > 0
def test_python_module_entrypoint_uses_unified_click_cli() -> None:
"""
``python -m omnigent`` must dispatch through the same click CLI
@@ -191,6 +224,7 @@ def test_wrapper_guard_bypass_reaches_cli_end_to_end() -> None:
(["run", "tests/resources/examples/hello_world.yaml"], False),
(["attach", "tests/resources/examples/hello_world.yaml"], False),
(["--help"], False),
(["--profiling", "--help"], False),
(["what does this repo do?"], True),
(["--system-prompt", "You are terse"], True),
# A single command-shaped word is an unknown subcommand, not
@@ -1042,10 +1076,11 @@ def test_kiro_command_is_registered_in_click_help() -> None:
def test_help_groups_harnesses_and_other_commands() -> None:
"""``--help`` lists a ``Harnesses`` section separate from ``Commands``."""
"""``--help`` lists global options and separates command categories."""
result = CliRunner().invoke(cli, ["--help"])
assert result.exit_code == 0, result.output
assert "--profiling" in result.output
assert "Harnesses:" in result.output
assert "Commands:" in result.output
# A harness launcher lands under Harnesses; a management command
@@ -3458,6 +3493,63 @@ def test_run_profile_sets_databricks_config_profile_env(
assert seen["value"] == "my-sp"
def test_bare_run_profile_shorthand_still_selects_databricks_profile(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The new root flag must not consume the historical bare-run spelling."""
from omnigent.cli import main
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
seen = _capture_profile_env_at_dispatch(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"omnigent",
"--profile",
"my-sp",
"--server",
"https://example.com",
"-p",
"hi",
],
)
main()
assert seen["value"] == "my-sp"
def test_global_profiling_coexists_with_run_databricks_profile(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Root profiling and ``run --profile NAME`` keep distinct semantics."""
monkeypatch.setenv("OMNIGENT_DATA_DIR", str(tmp_path / "data"))
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
seen = _capture_profile_env_at_dispatch(monkeypatch)
result = CliRunner().invoke(
cli,
[
"--profiling",
"run",
"--server",
"https://example.com",
"--profile",
"my-sp",
"-p",
"hi",
],
)
assert result.exit_code == 0, result.output
assert seen["value"] == "my-sp"
assert "Top Omnigent call paths" in result.stderr
profiles = tmp_path / "data" / "profiles"
assert len(list(profiles.glob("omnigent-cli-*.prof"))) == 1
def test_run_profile_wins_over_preset_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:

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