Skip to content

fix(setup): avoid termios setup crash on Windows - #1

Closed
scwf wants to merge 546 commits into
mainfrom
fix/windows-native-startup
Closed

scwf wants to merge 546 commits into
mainfrom
fix/windows-native-startup

Conversation

@scwf

@scwf scwf commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Use the shared stable_user_id() helper for qwen/kiro/hermes/kimi native bridge temp roots so imports do not require POSIX os.getuid().
  • Skip POSIX uid/mode checks in the shared secure-dir validation when uid semantics are unavailable.
  • Fall back from raw termios onboarding menus to numbered prompts on Windows while preserving wizard back navigation.

Testing

  • python -m pytest tests/onboarding/test_interactive.py tests/onboarding/test_wizard.py tests/test_claude_native_bridge.py::test_ensure_secure_dir_succeeds_without_getuid tests/test_qwen_native_bridge.py -q -k "not rejects_symlinked_ancestor"

Also verified locally on Windows with the installed omnigent tool:

  • omnigent server start; omnigent server status; omnigent server stop
  • @('q') | omnigent setup

@scwf

scwf commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor plz review this pr

@cursor

cursor Bot commented Jul 3, 2026

Copy link
Copy Markdown

PR Review: Fix Windows native startup fallbacks

Verdict: Approve. This is a focused, well-scoped fix for real Windows startup blockers. The approach matches existing conventions (stable_user_id(), tempfile.gettempdir(), numbered-menu fallbacks) and the tests cover the new behavior well.


What this fixes

Three separate Windows failures are addressed:

  1. Import-time crashesqwen, kiro, hermes, and kimi native bridges called os.getuid() at module load, which does not exist on Windows.
  2. Bridge dir validation false positives_ensure_secure_dir() in claude_native_bridge.py used getattr(os, "getuid", lambda: -1)(), so on Windows my_uid was always -1 and uid checks would reject every existing directory.
  3. Onboarding UXtermios/tty are unavailable on Windows; setup menus now fall back to numbered prompts instead of crashing.

Code review by area

Native bridge temp roots

Switching the four lagging bridges to stable_user_id() + tempfile.gettempdir() aligns them with the rest of the native bridge modules (claude, cursor, goose, etc.).

On POSIX this preserves historical behavior (stable_user_id() returns the numeric uid; gettempdir() respects TMPDIR). On Windows it produces a stable, path-safe namespace under the system temp dir. Using gettempdir() instead of os.environ.get("TMPDIR", "/tmp") is also a small improvement on non-Unix systems where /tmp is wrong.

_ensure_secure_dir() uid/mode guards

    getuid = getattr(os, "getuid", None)
    my_uid = getuid() if getuid is not None else None
    for ancestor in ancestors:
        ...
        if my_uid is not None and st.st_uid != my_uid:
            ...
        if my_uid is not None and (st.st_mode & 0o077) != 0:
            os.chmod(ancestor, 0o700)

This is the right tradeoff: symlink and directory-type checks still run everywhere; uid/mode enforcement is skipped only where POSIX uid semantics do not apply. The old lambda: -1 fallback was actively harmful on Windows.

Onboarding fallbacks

  • interactive.select() — catches ImportError for termios/tty on a TTY; existing termios.error handling remains as a second fallback.
  • wizard._arrow_menu() — same pattern, reusing the existing numbered fallback.
  • wizard._text_prompt() — degrades to click.prompt, consistent with the non-TTY path.
  • _safe() encoding round-trip in _select_fallback() — sensible fix for legacy console encodings (e.g. GBK); the test with "not installed ✗""not installed ?" is a good regression guard.

Strengths

  • Minimal diff — only the modules that were actually broken; other bridges already used stable_user_id().
  • Good test designtest_native_bridge_roots_do_not_require_getuid uses a subprocess to delete os.getuid before import, which correctly simulates Windows without platform-specific CI.
  • Author verification — manual Windows checks for omnigent server start/status/stop and omnigent setup add confidence beyond unit tests.
  • Test fixtest_claude_native_bridge.subprocess_bridge_root now uses claude_native_bridge._BRIDGE_ROOT instead of hardcoding /tmp/omnigent-{uid}, which keeps it correct cross-platform.

Minor suggestions (non-blocking)

  1. Wizard fallback testsinteractive.py gets two new tests; wizard.py gets the same termios fallback but no dedicated coverage. A small test mirroring test_select_falls_back_when_termios_is_unavailable for _arrow_menu would close that gap.

  2. _ensure_secure_dir without getuid — Consider a targeted unit test that stubs out getuid and asserts bridge dir creation succeeds. The subprocess import test covers the other bridges; claude’s secure-dir logic is the subtlest part of this PR.

  3. _safe() scope — The nested helper is recreated on every _select_fallback() call. Fine for onboarding; could be module-level if you want to avoid the closure, but not worth blocking on.

  4. Windows security note (documented tradeoff) — Skipping mode checks on Windows means bridge dirs rely on OS ACLs rather than 0o700. That is reasonable given the platform, but worth keeping in mind if Windows native harnesses become more widely used.


Testing

I could not run the full suite in this environment (missing project dependencies like sqlalchemy). The PR’s stated test commands are appropriate:

python -m pytest tests/onboarding/test_interactive.py tests/test_native_bridge_platform.py -q
python -m pytest tests/onboarding/test_interactive.py tests/test_native_bridge_platform.py tests/test_qwen_native_bridge.py -q -k "not rejects_symlinked_ancestor"

The -k "not rejects_symlinked_ancestor" exclusion is reasonable if that test is POSIX-specific symlink semantics.


Summary

This PR correctly unblocks Windows native startup with consistent patterns, fixes a latent bug in _ensure_secure_dir()’s fake-uid fallback, and improves onboarding resilience on non-POSIX terminals. I would merge it; the wizard test gap is the only item I’d optionally follow up on.

Open in Web Open in Cursor 

@scwf

scwf commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

@codex plz review this pr

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd4b7e4e90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread omnigent/onboarding/wizard.py Outdated
Comment on lines +84 to +85
except ImportError:
return _arrow_menu_fallback(options, default=default, disabled=disabled, multi=multi)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve back navigation in the Windows menu fallback

On Windows this new ImportError path is used for normal TTY menus because termios/tty are unavailable, but _arrow_menu_fallback only accepts numeric choices and never raises _GoBack. Most wizard menus use the default allow_back=True and callers rely on _GoBack to move to the previous step, so Windows users can get stuck completing the current menu flow or aborting the whole setup instead of going back; please add an explicit fallback back option such as q that raises _GoBack when allow_back is enabled.

Useful? React with 👍 / 👎.

@scwf

scwf commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

@codex updeted,plz review again

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41cd2849eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread omnigent/onboarding/wizard.py
@scwf
scwf force-pushed the fix/windows-native-startup branch 6 times, most recently from 01a9ab9 to 4e110e2 Compare July 6, 2026 02:58
@scwf scwf changed the title Fix Windows native startup fallbacks fix(setup): avoid termios setup crash on Windows Jul 6, 2026
kunyuchen and others added 17 commits July 14, 2026 19:25
* slack integration initial commit

* fix the issue where slack server preamturely terminates the response

* fix the issue where long responses could cause msg_too_long

* support slack mrkdwn

* address PR feedback

* pass pre-commit
…atus pair (omnigent-ai#2545)

An idle pi-native session kept queueing web messages client-side instead of
sending them: the composer showed "Send a follow-up (queued)" with a green
(idle) session dot, and only a tab switch unstuck it.

The pi extension minted a fresh response_id on every external_session_status
edge (agent_start running, agent_end idle). The web store clears its local
"streaming" flag only when the idle edge's response_id matches the running
edge that opened the turn (or when activeResponse is already null); with
mismatched ids neither branch fired, so status stayed "streaming" forever.
shouldQueueSend then queued every message and maybeFlushQueuedHead refused to
drain (both bail on status === "streaming"). switchTo hard-resets the store,
which is why a tab switch masked it. claude-native never hit this because its
forwarder reuses one turn-scoped id across both edges.

Mint a per-turn response_id in agent_start and reuse it in agent_end so the
running/idle pair matches, matching claude-native's contract.

Co-authored-by: Isaac
…-ai#2595)

* feat(web): show project name in pinned session hover flyout

Pinning a session lifts it out of its project folder into the flat
"Pinned" sidebar section, which dropped the visual cue for which project
it belongs to. Hovering a pinned, project-owned row now opens a flyout
showing the session title plus a folder icon and the project name,
reusing the existing project label already resolved for the kebab menu.

The flyout uses the shared HoverCard primitive (Cursor-style right /
top-aligned placement, matching AgentHoverCard) and is scoped to pinned
rows — non-pinned rows still convey their project via the folder they
sit in.

Co-authored-by: Isaac

* test(e2e_ui): cover pinned-row project hover flyout

Add a Playwright e2e that files a session into a project, pins it (lifting
it into the flat Pinned section), then hovers the pinned row and asserts the
flyout surfaces the folder icon + project name and the session title. Drives
the real project-move PATCH → label → pinned peel → hover flyout chain the
Sidebar unit tests mock out, and exercises the browser hover that opens the
Radix HoverCard (which jsdom can't).

Co-authored-by: Isaac

* feat(web): show full wrapping title in pinned project flyout

Session titles have no length cap (the server schemas and the rename
input are both unbounded), so the flyout's one-line `truncate` clipped
longer titles with an ellipsis. Clamp to 3 wrapped lines instead so the
full title shows and wraps while the card stays tidy — the complete text
stays in the DOM.

Co-authored-by: Isaac
…t-ai#2596)

* fix(web): align sidebar rows to a consistent two-column grid

The sidebar's top nav (New session, Search), section headers, project
folders, and session rows each carried their own horizontal padding, so
icons and labels landed at slightly different X positions down the list.

Pull every row onto one grid: icons on the left column, labels/nested
chats on the label column. New session uses gap-1 px-2, Search moves its
icon to left-2 / pl-7, flat session rows drop to px-2, and nested project
chats indent with pl-3 (footers follow at pl-5).

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
…e first turn (omnigent-ai#2478)

* fix(server): widen host-bound runner-connect grace to 10s

On the first message to a host-bound session, the server waits for the
create-time runner's tunnel to register before forwarding. The grace was
3s, but a freshly-launched runner needs ~5.5s to boot and connect its WS
tunnel. The wait timed out, abandoned the still-booting runner, and
relaunched a second one from scratch — roughly doubling cold-start latency
(~12.7s observed) and orphaning the first runner process.

Widen the grace to 10s so the first message rides the runner that create
already launched instead of relaunching. The wait stays event-driven (it
wakes the instant the runner's hello frame arrives) and still exits early
when the daemon convicts the runner dead, so a genuine startup failure
does not now cost a full 10s.

Co-authored-by: Isaac

* fix(web): keep "Working…" lit when live status beats a stale offline poll

The main chat's "Working…" indicator was suppressed whenever the open
session's runner read offline, checked before the running/waiting status.
The open-session `/health` poll is strict (runner_online true only while a
tunnel is registered) and runs on a 10s cadence, so on a fresh session's
first turn its first request lands while the runner is still connecting and
returns runner_online=false — held for up to 10s. The authoritative
`session.status: running` SSE edge arrives in that window but the gate
ignored it, so the indicator never appeared.

A session actively reporting running/waiting cannot have an offline runner,
so let its live status win over the lagging poll: only suppress on
known-offline when the session is otherwise idle (preserving the
don't-spin-a-dead-session-on-a-background-shell-tally case).

Surfaced by the faster host-bound runner connect (this branch): the turn
now starts inside the poll's stale-offline window instead of after it.

Co-authored-by: Isaac
…2599)

The pinned-session project flyout (omnigent-ai#2595) opens a Radix HoverCard on a
pinned, project-owned row. On a touch/mobile viewport there is no real
hover, so tapping the row to navigate also opened the HoverCard, which
then lingered over the chat page after navigation.

Gate the flyout off below the `md` breakpoint via useIsMobileViewport().
Forcing `projectFlyoutName` to null on mobile routes the row through the
plain ContextMenu/link path (no HoverCard mounted) and restores the
native `title` tooltip, since every downstream branch already keys off
that value.

Co-authored-by: Isaac
…ages unblock (omnigent-ai#2597)

The web client's maybeFlushQueuedHead gate checks s.status === 'streaming'.
That status only clears to 'idle' when the idle session.status SSE carries
the same response_id that set activeResponse at turn start. Pi's extension
generated a new ++sequence id for every event, so the running/idle pair
never matched and status stayed 'streaming' permanently — queued follow-up
messages were never dispatched even after Pi finished replying.

Fix: store the response_id set in agent_start in activeResponseId, and
reuse the captured value in agent_end. The fallback (a fresh id) fires only
when agent_end is reached without a prior agent_start response_id, which
should not happen in normal operation.
…igent-ai#2247)

* OMNI-1193: scheduled-task persistence foundation (SqlScheduledTask/SqlScheduledTaskRun + migration + store + tests)

Co-authored-by: Isaac

* OMNI-1193: drop plugins column from scheduled_tasks (reviewer: Omni resolves plugins host-side, no per-task field)

Co-authored-by: Isaac

* OMNI-1193: drop MySQL-illegal TEXT server_default on scheduled_tasks.metadata

Co-authored-by: Isaac

* OMNI-1193: store opaque scheduled_tasks text columns (prompt/metadata/error) as CompressedText

Co-authored-by: Isaac

* OMNI-1193: document Isaac→Omni id migration contract (mint new st_ id, keep isaac schedule_id in metadata) + fix stale metadata-Text comment

Co-authored-by: Isaac

* Make scheduled_tasks.owner_user_id nullable

Permit NULL so a schedule created with no authenticated user (single-user
/ OSS mode) can leave the owner unset, matching how create_session treats
the session owner as optional. Persistence-only: the fire-path resolution
(null -> reserved "local" user) lands in a later PR.

Co-authored-by: Isaac

* Make scheduled_tasks trigger recurring-only (drop run_at_ms one-shot arm)

The z6a2b3c4d5e6 migration is unreleased, so it is edited in place rather
than adding a follow-up migration.

Co-authored-by: Isaac

* Drop completed state from scheduled_tasks (recurring-only has no terminal state)

The z6a2b3c4d5e6 migration is unreleased, so the state CHECK is edited in
place rather than adding a follow-up migration.

Co-authored-by: Isaac

* Refine scheduled_tasks schema: timezone default + index tweaks

- timezone: add server_default="UTC" (model + migration) so raw inserts always get a valid zone
- drop unused ix_scheduled_tasks_agent_id (no query filters by agent_id)
- reshape ix_scheduled_task_runs_scheduled_task_id to (workspace_id, scheduled_task_id, scheduled_at, id) to cover list_runs()' scheduled_at DESC sort

All in-place on the unreleased migration; no follow-up migration.

Co-authored-by: Isaac

* OMNI-1193: trim redundant scheduled_tasks column comments to match sibling tables; reword sandbox_target comment

Co-authored-by: Isaac

* OMNI-1193: fix ruff C416 lint in scheduled_tasks migration test

Co-authored-by: Isaac

* OMNI-1193: genericize external-scheduler references in scheduled_tasks

Comment/docstring only — no functional code, column names, or values changed.

Co-authored-by: Isaac

* OMNI-1193: align sandbox_target width with hosts.sandbox_provider (String(32))

Co-authored-by: Isaac

* OMNI-1193: add nullable error_code to scheduled_task_runs

Short, queryable failure-classification token (String(64), no CHECK) alongside
the compressed error blob, so future retry logic can distinguish retryable vs
terminal failures. Threaded through the entity, migration, store, and tests.

Co-authored-by: Isaac

* OMNI-1193: drop sandbox_target from scheduled_tasks

sandbox_target was a nullable, persist-only column with no consumer.
Removed because Isaac scheduled-task proto has no compute-target field
(no merge-compat value) and compute-agnosticism is expressed by the
task carrying no compute preference at all — the fire path resolver
decides where to run.

Co-authored-by: Isaac

* OMNI-1193: drop harness_override from scheduled_tasks

harness is not an independent knob in Omni — it is a property of the
agent (agent_id); the composer harness/agent picker selects the
agent_id and there is no independent harness-override control. A
routine wanting a different harness points at a different agent_id, so
harness_override on scheduled_tasks was a dead column with no consumer.

Only removes harness_override from the scheduled_tasks feature.
model_override and reasoning_effort stay (real independent knobs), and
conversations.harness_override is untouched.

Co-authored-by: Isaac

* OMNI-1193: align owner_user_id width to String(128)

owner_user_id is written at fire time as a LEVEL_OWNER grant into
session_permissions.user_id, which is String(128). Every user-identity
column in the schema is String(128); the scheduled_tasks 255 was the
sole outlier and, being wider than the column it feeds, a >128-char
value could store but fail the grant write. 128 stays well under the
MySQL utf8mb4 indexed-key ceiling, so index safety is unchanged.

Co-authored-by: Isaac

* OMNI-1193: align workspace width to String(2048)

scheduled_tasks.workspace and conversations.workspace are the same
concept (an absolute filesystem path where the runner starts).
conversations uses String(2048); ours was the lone Text divergence.
Neither is indexed, so this is a consistency change, not functional —
matching conversations makes the mapping obvious.

Co-authored-by: Isaac

* OMNI-1193: fix stale scheduled_tasks doc comments

Documentation-only. No schema/type/logic changes.
- store module docstring: recurring-only (drop stale "or one-shot")
- create() docstring: state enum is active/paused/deleted (drop stale "completed")
- base_branch param docstring: genericize (drop Isaac-person name)

Co-authored-by: Isaac

* OMNI-1193: adapt scheduled_tasks to post-merge db_models split

Upstream omnigent-ai#2341 replaced the single class Base with OmnigentBase +
ConversationBase. Repoint SqlScheduledTask/SqlScheduledTaskRun to
OmnigentBase (control-plane/AP tables, siblings of policies/hosts/
user_daily_cost), NOT ConversationBase (conversation data-plane, may
live on a separate physical DB).

Also re-parent our alembic migration: omnigent-ai#2341 added two migrations after
z5, so repoint z6 down_revision z5a2b3c4d5e6 -> bb2c3d4e5f6a (the new
head) to linearize the chain to a single head.

Co-authored-by: Isaac

* OMNI-1193: drop scheduled_tasks.metadata column

Per PR review (aravind-segu): the metadata blob's only intended use was
source_schedule_id provenance on rows migrated from an external scheduler
— a single field better expressed as a typed column than a catch-all blob,
and not written by this persistence-only PR (always "{}"). Remove it now;
a typed column can be added if/when the external-scheduler merge lands.

Drops the column across model, migration, entity, store ABC + impl, and
updates the store + migration tests. 82 tests pass; ruff clean.

* OMNI-1193: store scheduled_task ids as Binary(16) UUIDs

Per PR review (aravind-segu): convert the owned scheduled-task id PKs to
16-byte UUIDs, aligning with the in-flight repo-wide Binary(16) UUID
convention. Adds a Uuid16 TypeDecorator (canonical UUID string in Python,
BINARY(16) on MySQL / BLOB/BYTEA elsewhere — same cross-dialect approach as
the existing _CKSUM32 digest column).

Converts scheduled_tasks.id, scheduled_task_runs.id, and the
scheduled_task_runs.scheduled_task_id self-ref. Cross-table reference
columns (agent_id, conversation_id, last_run_conversation_id) stay String
since their referents (agents.id, conversations.id) remain String PKs.

Updates the model, migration, entity + store docstrings, and both test
suites to use UUID-valued ids. 82 tests pass; ruff + mypy clean.

* OMNI-1193: add execution_target + host_id to scheduled_tasks

Persist where a routine fires, for the M2 sandbox/connected-host resolver
(no fire-path logic yet — persistence only, like the rest of this PR):

- execution_target: connected_host | managed_sandbox — the strategy the fire
  path resolves at run time (connected_host → owner's live host; managed_sandbox
  → provision/adopt a sandbox). Int-coded enum (connected_host=1,
  managed_sandbox=2) matching the state/kind/status pattern, server_default=1,
  CHECK IN (1,2). Existing rows default to connected_host (the V1 behavior).
- host_id: nullable String(64) — for connected_host, the specific host to pin
  (relates to hosts.host_id; no DB FK, Rule R032). NULL = owner's freshest
  online host; always NULL for managed_sandbox (provisioned under a
  deterministic id at fire time). Stays String, not Uuid16 — hosts.host_id is
  String and this PR doesn't own that table.

No per-routine provider column (provider comes from deploy config) and no auth
columns (identity rides on the resolved host). Threaded through model,
migration, entity, store ABC + impl, and the enum codec, with round-trip +
CHECK + default tests. 90 tests pass; ruff + mypy clean.

* refactor(db): read Uuid16 back as bare hex to match schema-wide UUID convention

Flip Uuid16.process_result_value from the dashed canonical form
(str(uuid.UUID(...))) to the bare 32-char hex string (.hex, no dashes),
aligning omnigent-ai#2247's scheduled-task id representation with omnigent-ai#2228's bare-hex
form so that PR's rebase is a no-op on representation. The 16 DB bytes
are unchanged — only the Python-side read-back string differs.

Also flip the test id-mint helper and the byte-ordering test literals to
bare hex so round-trip assertions hold, and update Uuid16 / ScheduledTask
docstrings. Includes the staged migration re-chain onto the current
upstream alembic head (down_revision bb2c3d4e5f6a -> 9d820f91deef).

Co-authored-by: Isaac

* docs(routines): strip internal PR/scheduler scaffolding from OSS comments

Remove self-referential PR-sequencing language ("This PR persists …",
"a later PR", "(future) scheduler", "persists the shape only") and
internal migration/merge-roadmap references ("external scheduler",
"reference platforms", MySQL roadmap clause) from docstrings and inline
comments in the Routines feature files.

No code, type, or schema changes — comment/docstring lines only.

* fix(store): resolve three blocking review findings on ScheduledTaskStore

Finding 1: update() could not clear host_id or last_run_conversation_id
to NULL because None was overloaded as both "unchanged" and "set to NULL".
Introduce a module-level _UNSET sentinel; None now means "set to NULL"
for those two nullable fields.  ABC kept in sync.

Finding 2: delete() orphaned scheduled_task_runs rows (no DB-level FK per
Rule R032, so cascade is application-owned).  Delete the task's runs in
the same session before removing the task row.

Finding 3 (doc-only): two :param id: docstrings in db_models.py said
"canonical UUID string" (dashed) when Uuid16.process_result_value returns
bare 32-char hex (no dashes).  Aligned with the entity and Uuid16 docs.

All changes covered by new TDD tests (red → green).
… "⋯" menu (omnigent-ai#2600)

Long diff lines previously overflowed with no way to wrap them, which is
painful in a narrow file-viewer pane (2–3 side by side). Add a "Wrap lines"
toggle that soft-wraps long lines in both diff panes (Monaco `diffWordWrap`),
persisted like the other view preferences.

Fold Find in file, Download, and the diff-only toggles (wrap lines, hide
whitespace) into a single "View settings" (⋯) menu, mirroring GitHub's
diff-settings menu and freeing toolbar width. Toggles keep the menu open;
actions close it. Active state shows a check mark, except whitespace whose
eye icon already flips open/closed.

Co-authored-by: Isaac
* test(e2e-ui): add a populated-sidebar visual snapshot

Seed a fixed session list covering every sidebar row type (Pinned, Projects group with an expanded folder + nested chat and an empty folder, flat Sessions with needs-response and running badges) so the row-alignment surface is gated. The empty-landing baseline stubs sessions empty, so that surface was previously untested — the area PR omnigent-ai#2596 touched.

Determinism: page.route stubs, a fixed page.clock so relative time pills don't drift, and a no-op /v1/sessions/updates socket. Baseline PNG generated by CI in the pinned image (label update-ui-snapshot).

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
…g panes (omnigent-ai#2604)

## Related issue

N/A

## Summary

- **Reload watcher: skip gitignored files.** The pod supervisor reloaded the
  backend on every `*.py` change under `omnigent/`, including gitignored files
  the build regenerates (notably `omnigent/_build_info.py`), causing needless
  reloads. It now builds a gitignore matcher from the repo's root `.gitignore`
  and `.git/info/exclude` and skips ignored paths — including files inside
  ignored directories (`build/`, `dist/`, `*.egg-info/`, …), matching git.
- **`--debug` flag.** Logs every observed file change into the combined pane as
  `watch: reload trigger <path>` or `watch: skip <path> (<reason>)`, so it's
  clear which change triggered (or didn't trigger) a reload. Quiet by default.
- **Pager log panes.** Per-process log panes are now a `less`-style pager with
  line/half/full-page movement, top/bottom jumps, follow-tail, line wrap, and
  forward/back incremental search (see the README Keys table).

## Test Plan

- `cargo build`, `cargo clippy --all-targets`, `cargo fmt --check` — clean.
- `cargo test` — passes single-threaded (the parallel-only flake in
  `create_skips_seed_when_real_config_absent` is a pre-existing env-var race in
  pod.rs, unrelated to this change).
- Verified `classify()` against the real repo `.gitignore`: `omnigent/cli.py`
  and `omnigent/inner/foo.py` reload; `_build_info.py`, `build/`, `*.egg-info/`,
  and `server/static/web-ui/` are skipped as gitignored; `__pycache__` and
  non-`.py` are skipped.
- `omnidev --help` shows the new `--debug` flag.

## Demo

N/A — pager-pane UI recording to be attached on the PR.

## Type of change

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

## Test coverage

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

## Coverage notes

Watcher classification is covered by unit tests in `watcher.rs` (.py filter,
`__pycache__`, gitignored file, file inside a gitignored dir). The gitignore
behavior was additionally verified against the real repo `.gitignore`, and the
`--debug`/pager panes were checked manually — the interactive TUI has no
automated harness.

## Changelog

`omnidev` no longer reloads on gitignored files, adds `--debug` to trace reload
triggers, and its log panes are now searchable `less`-style pagers

Co-authored-by: Isaac
…ent-ai#2602)

The session-row kebab / right-click menu opened "Add to project" / "Move
session" as a side-flyout submenu (C.Sub/SubTrigger/SubContent). On mobile
there's no horizontal room for a side flyout, so it overflowed and didn't
work.

On mobile, the project item is now a plain menu item that swaps the menu
body in place: a local `view` state ('main' | 'projects') replaces the main
actions with the existing ProjectPickerMenu (search + list + Create new
project) plus a chevron-left "Back" row that returns to the main view.
Selecting the item and Back both preventDefault so the menu stays open
rather than closing on select. Desktop keeps the native side-flyout submenu
unchanged. Because the menu body is authored once through the shared
MenuComponents bundle, the in-place view works for both the kebab dropdown
and the right-click context menu families.

Co-authored-by: Isaac
…SDK (omnigent-ai#2605)

* refactor(hindsight): rename memory extra to hindsight; gate tools on SDK

## Related issue
N/A

## Summary
- Rename the optional install extra `memory` -> `hindsight` (the extra that
  pulls `hindsight-client` for the Hindsight long-term memory tools), so the
  extra name matches the tools it enables. Updates `pyproject.toml`,
  `uv.lock`, the install hint, docstrings, and `examples/remy/config.yaml`.
- Hide the three Hindsight tools from the builtin list when
  `hindsight-client` is not installed: they're now absent from
  `BUILTIN_NAMES` / `INSTANTIABLE_BUILTINS` and not instantiable, and the
  onboarding `list_builtin_tools` helper no longer advertises them. The
  presence probe uses `importlib.util.find_spec` so the SDK and its deps
  (aiohttp, ...) stay lazy.

## Test Plan
- `ruff format` + `ruff check` clean; `pre-commit run` passes on all changed
  files (including the `normalize-uv-lock-registry` hook).
- `pytest tests/tools/builtins/test_hindsight.py
  tests/tools/builtins/test_registry_unified.py tests/spec/test_validator.py`
  -> 79 passed; full `tests/tools tests/spec tests/onboarding` -> green (one
  unrelated `databricks_sdk_installed` failure was an env artifact from running
  `--extra dev` instead of `--extra all`; passes with `--extra all`).
- New `test_hindsight_tools_absent_from_registry_when_sdk_missing` hides
  `hindsight_client` from the finder, reloads the registry, asserts the tools
  are absent + not instantiable, and restores the finder in `finally` (no
  state leakage — verified by running it before the registry-size test).

## Demo
N/A

## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [x] 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 extra rename is exercised by the existing registry-size test (which lists
the hindsight names) and the lock line. The gating is covered by the new unit
test. Manually verified `_hindsight_available()` returns True with the SDK and
False when hidden from the finder, in both the registry and the onboarding
helper.

## Changelog
`omnigent[memory]` is renamed to `omnigent[hindsight]`; the Hindsight memory
tools are now hidden from the builtin list when `hindsight-client` is not
installed.

* fix(uv.lock): complete hindsight extra rename in lock metadata

The rename commit updated the requires-dist marker but missed the
provides-extras list and the package optional-dependencies mirror, so
`uv sync --locked` (every CI job's install step) failed.
…#2603)

* fix(spawn): clarify sys_session_send schema to prevent agent/title-in-args confusion

Pi was putting 'agent', 'title', and 'session_id' inside the args object
instead of as top-level fields. It also tried passing 'model' via session_id
mode where it has no effect.

- Tool description now explicitly states that agent/title/session_id are
  TOP-LEVEL fields and model/purpose go INSIDE args, with a concrete
  correct example.
- args description now warns against putting agent/title/session_id inside
  args, and clarifies that model only applies on session CREATE (first named
  send), not on continuation or session_id sends.

* revert(pi-native): remove pi_native_credentials change from sys_session_send fix

* fix(pi-native): route non-Claude models to correct provider in models.json and --provider arg

Two fixes for model override with non-Claude models (GLM, GPT, etc.):

1. to_models_config: don't append the selected model to the Anthropic
   (omnigent) provider if it already lives in an additional_providers entry
   (omnigent-openai/openai-completions). Previously GLM was appended to
   the anthropic-messages provider, causing Pi to attempt to call GLM via
   the wrong wire protocol.

2. pi_native_provider_launch: pass --provider omnigent-openai (not omnigent)
   when the selected model lives in an additional_providers entry. Previously
   --provider omnigent was always passed, so Pi couldn't resolve models that
   only exist under omnigent-openai.
## Related issue

N/A

## Summary

- Adds `omnigent://<hostname>/c/<session_id>` deep links to the Electron desktop shell: an OS-clicked link opens that session on that server, reusing an existing window in-place when one is already on it.
- Window handling is the careful part — a pure, unit-tested `chooseDeepLinkStrategy` picks reuse-in-place (focus + tell the SPA router to navigate, no reload), reuse-with-reload (pinned but mid-SSO), open-known (frictionless new window), or consent-unknown (native dialog, since pinning a new origin is a privilege grant). The workspace mount probe runs only AFTER consent, so a link to an attacker-chosen server makes no pre-consent network request.
- The window's server identity (`serverUrl`, used by `omnigent host --server`) is kept clean of the `/c/<id>` path while the load URL carries it; the mount-aware join keeps `/ml/omnigents` from being dropped.

## Test Plan

- `cd web/electron && node --test` — 195 tests (19 new deep-link decision tests + wiring guards).
- `cd web && npx tsc -b` clean; `npx vitest run src/hooks/useIdleNotifications.test.tsx src/lib/nativeBridge.test.ts src/shell/AppShell.test.tsx` — 160 pass.
- Manual (dev, local server `127.0.0.1:6767`): warm-start reuse-in-place — with the app connected and viewing conversation A, `npm start -- 'omnigent://127.0.0.1:6767/c/<B>'` (second terminal) switches the existing window to B in-place, no reload. Confirmed via the diagnostic logs: `strategy=reuse-inplace ... send open-path /c/<B>`. Requires the web UI rebuilt (`cd web && npm run build`) since the desktop loads the server's built SPA.

## Demo

N/A — no visible UI change beyond in-app navigation triggered by an external link.

## 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

Unit tests cover the pure decision logic (`web/electron/test/deepLink.test.js`: parse + the reuse/reload/open-known/consent-unknown table) and `web/electron/test/main.test.js` wiring guards (open-url/second-instance/argv ingestion, serialized queue, scheme registration, mount-aware path join, clean serverUrl, and the post-consent probe placement). The OS-dispatch + window orchestration can't be unit-tested without an Electron launch, so it was verified manually with the local server (warm-start reuse-in-place confirmed via logs).

## Changelog

`omnigent://<hostname>/c/<session_id>` links open that session in the desktop app, reusing an open window on that server in-place
…mnigent-ai#2609)

A claude-native cold resume rebuilds Claude Code's local transcript from
Omnigent's stored items. Image tool results (screenshots) are persisted as
a stringified content-block array, and the rebuild dropped that string
straight into the `tool_result` content. On `claude --resume`, Claude sent
the base64 to the API as plain *text*, so a single screenshot cost ~250K
tokens instead of the ~1.5K an image block costs. A conversation that fit
comfortably while live then overflowed the context limit on reconnect
("Prompt is too long"), and the model no longer saw the screenshots as
images.

Rehydrate `text`/`image` block arrays back into real content blocks so the
resumed request sends images as images. Non-block outputs (plain text,
other JSON shapes, API-unsupported block types) stay raw strings, so their
resume behavior is unchanged.

Measured on the reported conversation: base64-as-text drops from ~253K
tokens to 0, with all 6 screenshots restored as image blocks.

Co-authored-by: Isaac
…mnigent-ai#2610)

* perf(web): reduce sessions API calls on initial page load

On the landing page, ChatPage fired two redundant GET /sessions calls:
- useConversations() with includeArchived=false, duplicating the sidebar's
  useConversations('', true) which uses the same endpoint with a different
  cache key
- useAgents() unconditionally, even though the agent picker is only visible
  once a session is open

Fix both:
1. ChatPage's useConversations() now passes includeArchived=true, sharing
   the cache key with the sidebar and eliminating the duplicate fetch.
2. useAgents gains an  option; ChatPage passes enabled=!!urlConvId
   so the sessions?limit=100 scan is skipped on the landing screen where
   NewChatLandingScreen's useAvailableAgents already covers agent discovery.

Net effect: 5 → 3 GET /sessions calls on initial load.

* fix(web): consolidate useConversations callers to share sidebar cache key

AppShell, usePermissions, RunnerHealthProvider, and useIdleNotifications
all called useConversations() with the default includeArchived=false,
creating a separate cache entry from the sidebar's includeArchived=true
fetch and causing a duplicate GET /sessions?limit=20 call on every load.

Switch all four to useConversations("", true) so they share the sidebar's
["conversations", "", true] cache key. The behavior change is minimal:
these hooks only inspect existing sessions by id or aggregate counts, so
seeing archived sessions in the list is either neutral or beneficial
(e.g. useCanEdit can now resolve permissions on an archived session).

* fix(web): fix CommandPalette cache-key mismatch after includeArchived consolidation

CommandPalette was calling useConversations(query, false), designed to share
AppShell's old useConversations() cache entry. After switching all callers to
includeArchived=true, CommandPalette's false key no longer matched anything,
reintroducing the duplicate fetch.

Switch to includeArchived=true and filter archived rows client-side in the
sessions memo so the palette still only lists active sessions.

* test(web): update CommandPalette test for includeArchived=true
fanzeyi and others added 18 commits July 21, 2026 02:03
## Related issue

N/A

## Summary

Main's lint workflow failed because the desktop update E2E test retained extra trailing blank lines. Apply Ruff's formatting so the all-files pre-commit check remains clean.

## Test Plan

- `.venv/bin/pre-commit run --all-files --show-diff-on-failure`

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

## Coverage notes

Formatting-only correction; the full all-files pre-commit suite passes.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
…2886)

* perf(web): lazy-load Shiki so it leaves the main bundle

Shiki's engine (including its WASM regex engine) was pulled into the app's
main entry chunk even when no code block ever rendered. Two eager importers
kept it there: code-block.tsx and the @streamdown/code highlighter plugin
wired into chat markdown via streamdown-security.ts.

Defer both. code-block.tsx now imports shiki at highlight time inside its
existing per-language cached getHighlighter helper. A new lazyCodePlugin
wraps @streamdown/code, satisfying Streamdown's CodeHighlighterPlugin
contract (default themes synchronously; highlight() returns null until the
engine loads, then resolves tokens through the callback) while deferring the
@streamdown/code import — and with it shiki — to the first highlight call.

Rendering, theming, language handling, and public APIs are unchanged. Shiki
now splits into a separate on-demand chunk: the main entry chunk drops from
4,551.81 kB to 4,356.25 kB (~196 kB raw, ~60 kB gzip), and Vite no longer
reports the ineffective-dynamic-import warning.

Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com>

* test(web): prove lazy Shiki highlighting through Streamdown + harden callback

Address cross-vendor review of the lazy-Shiki change.

Verified lazyCodePlugin matches Streamdown's real consumption contract:
HighlightedCodeBlockBody runs highlight() inside a useEffect and stores the
result via setState — `let r=o.highlight({...}, c=>{i(c)}); r&&i(r);`
(streamdown/dist/highlighted-body-OFNGDK62.js). Returning null keeps the raw
code in state; the callback calls setState, forcing a re-render with the
highlighted tokens. The highlighted body is itself React.lazy + Suspense
(chunk-BO2N2NFS.js), so raw text paints first and highlighting streams in.
So the null-then-callback path reliably produces highlighted output.

- Add streamdownCodeHighlight.test.tsx: renders MessageResponse (which uses
  STREAMDOWN_PLUGINS with code: lazyCodePlugin) on a fenced code block,
  asserts raw code shows immediately, then waits for the lazy @streamdown/code
  import + callback and asserts multiple per-token colored spans appear
  (Streamdown colors tokens via the --sdm-c CSS custom property).
- Harden highlight() against double callback invocation with a fire-once guard
  so the callback runs exactly once whether the real plugin resolves via its
  return value (sync cache hit) or its own callback. Add a unit test asserting
  the callback fires exactly once.
- Clarify supportsLanguage: Streamdown has zero call sites for it/
  getSupportedLanguages, and highlight() falls back to "text" for unknown
  languages, so the optimistic pre-load answer is safe.

Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com>

* test(e2e): assert chat code blocks lazy-load Shiki highlighting

Regression guard for the lazy-Shiki change: seeds a deterministic
assistant message with a fenced code block and asserts the observable
syntax-highlighted token spans appear once the on-demand Shiki import
resolves, proving highlighting survives the deferral.

Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com>

* style: apply ruff format to lazy-Shiki e2e test

`ruff format` collapses the multi-line `wait_for_function` string
concat onto one line; matches the pre-commit CI fix so the check
passes.

Co-authored-by: Isaac

* test(ui-snapshot): wait for lazy Shiki highlight before chat capture

The lazy-Shiki change defers `@streamdown/code`, so the fenced code
block first paints raw and only re-renders with syntax-highlighted
token spans once the on-demand import resolves. The visual snapshot
was capturing the pre-highlight frame, drifting from the committed
(highlighted) baseline and failing the UI Snapshot gate.

Wait for the `--sdm-c` token spans (same signal the lazy-Shiki e2e
test uses) before capture so the render is highlighted and matches
the existing baseline — no baseline regen needed.

Co-authored-by: Isaac

* test(ui-snapshot): update chat baseline for lazy-Shiki render

The lazy-Shiki change defers `@streamdown/code`; in the pinned headless
Playwright renderer the fenced code block paints uncolored even after the
token spans mount (confirmed across two CI runs — the DOM wait added last
commit does not repaint the colors at capture). Highlighting works in a
real browser, so this is a snapshot-environment artifact, not a UX
regression. Adopt the CI-rendered baseline (byte-identical to the gate's
render) so the visual gate matches, and keep the token-span wait so the
capture is the settled post-import DOM rather than a mid-tokenization frame.

Co-authored-by: Isaac

* test(ui-snapshot): fix chat snapshot flake on lazy Shiki highlight

The chat baseline flaked between highlighted and raw code renders. The
lazy `@streamdown/code` import mounts the colored token spans a frame
before the browser composites their colors, so waiting on span presence
raced the paint — the screenshot sometimes caught the raw frame.

Wait until the tokens resolve more than one distinct computed color (the
raw fallback is a uniform `inherit`), then flush two animation frames so
the colors are painted before capture. Restore the highlighted baseline
as the correct target (a prior commit had adopted a raced raw render).

Co-authored-by: Isaac

---------

Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com>
Co-authored-by: Daniel Lok <daniel.lok@databricks.com>
)

Bump the SDK-proxy harness subprocess and native CLI pane idle-reap
defaults from 30 minutes to 1 hour so short lulls between turns don't
tear down live sessions. Both defaults intentionally mirror each other;
the runner-level watchdog was already at 1 hour, so it now consistently
outlives the inner reapers it contains. Both remain env-overridable.

Co-authored-by: Isaac
…mnigent-ai#2989)

* fix(telemetry): track sdk harness name in SessionCreatedEvent

SDK sessions (claude-sdk, openai-agents, codex, etc.) previously emitted
`harness: null` on the SessionCreatedEvent because only native agents have
a `native_agent.harness` attribute. Fall back to `_resolve_harness(conv)`,
which already handles harness_override and spec lookup, so every harness
kind is now represented in telemetry.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(session-ui): support HTTP headers on MCP servers in session UI

Adds the ability to set, view, and edit HTTP headers (e.g. Authorization)
on HTTP-transport MCP servers through the session agent info panel.

Backend:
- MCPServerSummary now includes a headers field; values are always
  [REDACTED] in API responses (only key names are exposed).
- UpsertMCPServerRequest accepts headers: dict[str, str] | None.
  None preserves existing headers; {} clears them.
- New _apply_headers() helper replaces the old _preserve_keys() call for
  headers so edits via the UI actually take effect rather than always
  restoring the bundle's headers.
- Fixed sessions.py and builtin_agents.py MCPServerSummary construction
  to populate headers (previously always returned {}), which caused
  headers to disappear when reopening the edit dialog.

Frontend:
- McpFormState/UpsertMcpServerInput/McpServerSummary all carry headers.
- McpServerManagerDialog shows a key-value editor for HTTP headers
  (add row with +, remove with x, values show as [REDACTED] for
  existing headers).
- Fixed AgentInfoButton popover closing when the MCP manager Dialog
  opens: uses onInteractOutside/onFocusOutside on PopoverContent to
  suppress Radix's outside-click dismiss while a nested dialog is open.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(create-agent): accept KEY: VALUE format in headers textarea

parseKVLines only split on '=' so users typing the natural HTTP header
format (Authorization: Bearer ...) got silently dropped. Now accepts
both '=' and ':' as separators, taking whichever comes first.
Updated the placeholder to show the colon form.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(mcp-headers): preserve real secrets when [REDACTED] sent on edit

When a user opens the MCP server edit dialog, header values come back
as [REDACTED] from the API. If they save without changing those values
the client sends { Authorization: '[REDACTED]' }, which was being
written literally into the bundle YAML — overwriting the real token.

_apply_headers now treats a value equal to the '[REDACTED]' sentinel
for an existing key as 'preserve the stored value', restoring it from
the existing bundle entry instead of writing the placeholder.

Also reverts unrelated package-lock.json churn and adds a round-trip
integration test covering the edit-with-existing-headers scenario.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* chore: regenerate openapi.json for MCP headers fields

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(mcp-headers): send {} to clear headers when all rows removed

When editing a server and removing all header rows, the frontend was
sending null (preserve) instead of {} (clear), so stale auth tokens
were silently kept in the bundle.

null now only means 'preserve' for new servers (no originalName).
Editing an existing server with zero rows sends {} to explicitly clear.

Adds integration test covering the clear-all path.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…ai#2999)

When deleting a conversation with N descendants, each FTS row was
deleted in a separate DELETE statement. Replace the per-ID loop with
a single DELETE ... WHERE conversation_id IN (...) via the new
delete_fts_by_conversation_ids helper. The single-ID function is
kept intact for other callers.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…nigent-ai#2985)

Runners were reported to be "randomly dying" with no explanation in the
runner log — an uncaught exception left only a bare traceback on stderr,
and orderly shutdowns (signal, idle timeout, tunnel drop, parent death)
logged nothing at all.

Attribute the exit on each hookable path so the runner log always says
why it stopped:
- uncaught exceptions via sys.excepthook (with traceback) — the
  silent-crash case
- SIGTERM/SIGINT, recording the specific signal
- idle timeout, websocket tunnel close, and the parent-death hard-exit
  backstop (logged at the os._exit call site, which skips atexit hooks)
- fatal server rejection keeps its concise stderr message

SIGKILL and os._exit remain uncatchable in-process; the absence of an
exit line is itself the signal that the runner was killed uncatchably.

Co-authored-by: Isaac
…mnigent-ai#3022)

Per-parent child-title uniqueness was enforced by a UNIQUE index on
(workspace_id, parent_conversation_id, title_hash), where title_hash was a
16-byte sha256(title)[:16] mirror of title maintained solely to key that
index. Reads never used it (the runner's find-or-create pre-check filters
title, whose 3rd index column was title_hash), so it was pure write
amplification.

Move the check into create_conversation: a per-parent (parent, title)
existence SELECT served by idx_conversations_parent, raising
NameAlreadyExistsError on a hit. Only children are scoped; top-level (NULL
parent) sessions may reuse titles freely, as before. Drop the index, the
title_hash column, the two hash helpers, the _CKSUM16 alias, the ORM default
and the two rename-path recomputes, and the store's IntegrityError->title
translation (the id-PK branch stays).

Trade-off: the DB index was the atomic backstop for concurrent same-name
spawns (tool calls dispatch concurrently within a turn). The app check is
best-effort, so a rare concurrent duplicate spawn now yields a stranded
duplicate child + a wasted runner instead of a clean error. Bounded, not
corruption; the common repeat-send path is unaffected (served by the runner
pre-check).

Migration 72e6dceae14f. SQLite drops/recreates idx_conversations_parent by
hand around the batch rebuild so its DESC ordering survives; MySQL/Postgres
use native DROP COLUMN. Downgrade re-adds title_hash, back-fills it in
Python, and restores the unique index.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
* 🐛 fix(codex): Surface native subagents in UI

Register subAgentActivity starts before child events hit the stale-thread guard.

Cover bridge routing plus real native-spawn and Agents-rail end-to-end journeys.

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* ✅ test(codex): Verify subagent completion

* 📝 docs(codex): Add native subagent demo

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
)

_COMPACT_LOCKS existed but was never acquired, so concurrent compact
events could both observe idle and run at once. Hold a WeakValueDictionary
lock per session, recheck status after acquire, and cover the race with a
deterministic concurrency test.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
…ifier (omnigent-ai#2586)

Descriptions told the model to cancel with task_id while dispatch already returned handle_id. Align schemas/messages on handle_id and keep task_id as an identical compatibility alias scheduled for removal in 0.8.0.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
…nt-ai#2587)

Malformed JSON previously fell through to {}, which could run a
default/no-argument system tool. Require a JSON object and return the
canonical structured error before dispatch.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
…mnigent-ai#2588)

Idle shutdown was terminating runners while sys_call_async results were
still in flight because has_active_work only checked foreground/harness
turns. Keep the runner alive for live async tasks, timers, and parked
approvals without pinning on completed or housekeeping work.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
…nigent-ai#2093)

* feat(server): streaming dictation endpoint (local speech-to-text)

Adds WS /v1/dictation/stream + GET /v1/dictation availability probe,
backed by a lazily-loaded sherpa-onnx streaming transducer (new
optional extra: omnigent[dictation]) with optional online
re-punctuation. Fills the gap documented in web/electron/README.md:
dictation where the browser Web Speech API has no backend, with audio
never leaving the operator's infrastructure.

A deterministic fake engine (OMNIGENT_DICTATION_ENGINE=fake) keeps CI
hermetic and will drive the Playwright e2e test.

See designs/server-dictation.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): stream server dictation into the composer mic button

When the browser has no Web Speech backend (Electron, Firefox,
Chromium), the mic button now falls back to the server recognizer:
GET /v1/info advertises dictation_available, an AudioWorklet
downsamples the mic to 16 kHz PCM over WS /v1/dictation/stream, and
partial transcripts form live in the composer via a replaceable
interim region (useDictationInsert) shared by ChatPage and
NewChatDialog. Web Speech behavior is unchanged where it works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e-ui): dictation loop against the fake engine

Fake mic (Chromium fake media device) -> AudioWorklet -> dictation WS ->
OMNIGENT_DICTATION_ENGINE=fake -> transcript lands in the composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: ruff format + regenerated openapi.json for dictation routes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: prettier formatting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e-ui): honor plugin context args in the dictation test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: drop the caller-less GET /v1/dictation probe

ponytail review: the web UI only reads dictation_available from
GET /v1/info, so the dedicated probe endpoint had no caller. Also
simplify the engine singleton (config never changes mid-process;
tests inject engine_provider) — a failed load still caches nothing,
so gaining models doesn't require a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: hardware sizing table for dictation models

Measured on Apple M-series and an Intel N95 mini-PC: the default
Nemotron 0.6B is too slow for N95-class servers (0.6-0.7x realtime);
the mid-size streaming zipformer decodes 1.4-2.3x realtime there in
~190 MB and held accuracy in spot checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(server): remote dictation worker relay with local fallback

OMNIGENT_DICTATION_REMOTE_URL relays takes to a dictation worker on a
beefier LAN box over the existing wire protocol; local models (when
installed) serve as a lazy fallback when the worker is down. Ships a
standalone single-route worker entrypoint
(python -m omnigent.server.dictation_worker). Motivated by real
hardware: an N95 main server decodes the default 0.6B model at only
0.6x realtime, but a workstation on the same LAN runs it at 9x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deps): pin sherpa-onnx-core + numpy explicitly in the dictation extra

sherpa-onnx's wheel metadata declares its native payload package
(sherpa-onnx-core, which carries libonnxruntime) inconsistently across
platforms, so it was missing from uv.lock — failing the hashed OSV
audit in CI and breaking aarch64 installs. Pinning it explicitly fixes
both and removes the fetch script's aarch64 fixup. numpy is imported
directly by the engine, so declare it instead of riding transitives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: harden dictation take lifecycle (adversarial review findings)

Server: the route now closes the engine stream handle on every exit
path — an abandoned take (browser vanished mid-dictation) previously
leaked the remote relay's worker WebSocket and reader thread, holding a
worker capacity slot forever and eventually starving dictation for
everyone.

Web client, all confirmed by review:
- useDictationInsert strips the interim region only when the draft
  still ends with the exact text it inserted, so dictation can never
  delete user-typed text; ref bookkeeping moved out of the setState
  updater (StrictMode double-invokes updaters).
- The worklet flushes its partial chunk before stop() tears the graph
  down — trailing speech under the 100 ms boundary was being clipped
  from every take.
- Client ready/stop budgets now exceed the server's cold-load and
  worker-flush budgets (40 s / 15 s), so slow first takes and slow
  tail flushes no longer fail or drop text spuriously.
- The 1013 at-capacity close surfaces as "busy — try again" instead of
  "unavailable", and engine-init error frames surface their message.
- A socket close during audio-graph setup now fails the start instead
  of resolving a dead session that silently drops all audio.
- Web Speech network-error fallback is per take, not sticky: a
  transient blip in real Chrome no longer permanently downgrades the
  page to the server model, and stale events from the dead recognizer
  can no longer clobber the live server take's state (which could
  leave the mic recording while the button showed idle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: dictation model choices for other languages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): format dictation files

* fix(server): close dictation takes even when the task is cancelled

An ASGI server cancels the websocket handler task on shutdown. The
cleanup awaited asyncio.to_thread(handle.close) inside finally, so the
CancelledError could arrive before the worker thread ran close() --
about half the time, measured. contextlib.suppress(Exception) never
caught it: CancelledError is a BaseException.

Create the close task before the first await point and shield it, so it
runs to completion while cancellation propagates. Hold a strong ref
(asyncio keeps only a weak one) and retrieve the result so a failing
close logs instead of warning.

Also corrects the comments: an abandoned take is reaped by the ASGI
server's ping timeout (~20s), not held forever. Verified against a live
worker with OMNIGENT_DICTATION_MAX_STREAMS=1.

* refactor(dictation): split out remote, add engine registry, fold beautify

Keep this PR focused on local dictation and make future model swaps cheap:

- Defer the remote worker (RemoteDictationEngine, dictation_worker.py, and
  the close-on-cancel machinery that existed to release a worker slot) to a
  follow-up PR. Remote only helps a narrow deployment; local sherpa runs at
  many-times realtime on any normal machine, so this does not block testing.
- Select engines by name from a registry (register_engine); get_engine and
  engine_availability resolve from it instead of an if/elif ladder. Adding
  an engine is one call with a factory + availability probe.
- Fold punctuation into the sherpa engine and drop beautify from the
  DictationStreamHandle protocol. Emitted text is display-ready, so the
  seam is PCM-in -> text-out -> close; models that punctuate themselves
  (Whisper, Parakeet) implement nothing extra.

Co-authored-by: Isaac

* chore: re-trigger CI checks

Empty commit to re-run the security scan and CI on this PR.

Co-authored-by: Isaac

* build(deps): minimize dictation lock diff to sherpa-only, public index

The merge re-lock rewrote every uv.lock URL to the Databricks internal
index proxy and would fail the public-registry lint. Restore public
pypi.org / files.pythonhosted.org URLs so the lockfile diff versus main
is only the two dictation packages (sherpa-onnx, sherpa-onnx-core), with
no unrelated churn.

Co-authored-by: Isaac

* fix(web): sync ServerInfo test fixtures with merged capability fields

The main merge made single_user/sharing_mode/public_sharing_enabled
required on ServerInfo while dictation_available became required from this
PR, but four test fixtures each construct a ServerInfo literal missing the
other side's fields, failing tsc (and the web build via Docker/E2E-UI).
Add the missing fields so every fixture is a complete ServerInfo.

Co-authored-by: Isaac

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ubuntu <kerry.chang@your.hostname.com>
…mnigent-ai#2584)

* fix(sessions): avoid GeneratorExit on SSE stream disconnect cleanup

Yielding [DONE] from _stream_live_events finally raised RuntimeError on
client aclose; keep finally cleanup-only and aclose the subscribe slot.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

* chore(openapi): regenerate session stream description

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

---------

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
…omnigent-ai#3014)

* feat(scheduled tasks): track run completion + expose run history

The fire path records a scheduled_task_runs row as `running` and never
revisits it, so runs stayed `running` with finished_at=NULL forever even
after the agent turn completed (the FU-1 gap confirmed in prior E2E).
list_runs also existed in the store but was exposed by no REST route.

Add a periodic reconciliation backstop + run-history endpoint:

- Store `update_run` (conditional WHERE status=running, idempotent — an
  already-terminal run is never clobbered and concurrent sweeps can't
  double-transition) and `list_runs_by_status_all_workspaces` (the sweep
  source). ScheduledTaskRun entity now carries workspace_id so the sweep
  can re-enter each run's workspace_scope.
- `run_reconciler.py`: a 60s asyncio loop (own module, off the
  ScheduledTaskScheduler) that reads each running run's conversation and
  transitions it — completed transcript -> succeeded; a failure label /
  missing conversation -> failed(code); live_status running/waiting is a
  cheap pre-filter. A run past a 6h max-age with no terminal state is
  force-failed (error_code=incomplete) so every run eventually terminates.
  Wired into the server lifespan next to the scheduler.
- `GET /v1/scheduled-tasks/{id}/runs`: owner-scoped run history (404 if
  not owned), API-stable field naming.

No schema/migration change — status codec already had succeeded/failed and
the columns (finished_at/error/error_code) already exist. FU-3 + omnigent-ai#2978
semantics intact (owner via user_id; API-stable owner_user_id JSON key).

Tests: update_run transitions + idempotency; reconciler classification
matrix (completed->succeeded, errored/cancelled->failed, in-flight and
young runs left alone, stale->failed(incomplete)); GET runs 200/empty/404.
Full targeted suite green (155). E2E on a live server + connected host:
a real timer fire's run flipped running->succeeded with finished_at set
(the exact thing that stayed running before), readable via the runs
endpoint; honest-fail still records failed(no_online_host) and the sweep
leaves terminal runs untouched.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(scheduled tasks): make run completion event-driven (replaces poll)

Replaces the 60s all-workspaces reconciliation poll from the previous commit
with an event-driven completion hook + a poll-free orphan backstop, matching
how the sibling scheduled-task systems reconcile (at a lifecycle boundary, not
on a timer).

Primary mechanism: a completion hook
(``session_live_state.persist_scheduled_run_completion``) fired from
``_publish_status`` the instant a fired conversation's turn reaches a terminal
edge (idle -> succeeded, failed -> failed+error_code). It rides the same
long-lived SSE relay that already persists ``live_status`` for a browserless
scheduled fire, routed through the same ordered/contextvar-copying executor so
the run's ``workspace_scope`` reaches the write thread. A reverse lookup
(``get_running_run_by_conversation``, backed by a new
``(workspace_id, conversation_id)`` index) finds the run; the idempotent
conditional ``update_run`` (WHERE status=running) transitions it and never
clobbers an already-terminal row. For the common (non-scheduled) conversation
the lookup returns None and the hook is a cheap no-op.

Orphan backstop (no periodic poll): the ``ScheduledRunReconciler`` becomes a
ONE-SHOT startup sweep (reconciles runs left ``running`` by a restart
mid-fire), and a lazy-on-read pass at ``GET /v1/scheduled-tasks/{id}/runs``
force-fails a task's runs past the 6h max age (``incomplete``). Together they
keep the invariant "every run eventually reaches a terminal state" without a
recurring background sweep.

One migration: the ``conversation_id`` index. FU-3 / omnigent-ai#2978 owner semantics,
the ``GET /runs`` response shape, and the fire-time ``_record_run`` writes are
unchanged.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* refactor(scheduled tasks): drop startup sweep, lazy-on-read is sole backstop

Simplifies the orphan backstop per review. The event hook already transitions
every normal run the instant its turn ends; the boot-time startup sweep is
removed entirely (fewer moving parts). A run orphaned by a mid-fire restart
that nobody ever opens staying `running` in the DB is harmless until read, and
reading it fixes it.

Changes:
- Remove `run_startup_sweep`, the `ScheduledRunReconciler` class, and its
  lifespan wiring in app.py. `run_reconciler.py` reduces to the stale-run
  policy: the constants + a shared `force_fail_stale_runs` helper (pure
  age-based, no conversation I/O).
- Run the lazy force-fail-stale reconcile on BOTH read endpoints:
  - `GET /v1/scheduled-tasks/{id}/runs` (detail, already there).
  - `GET /v1/scheduled-tasks` (list, ADDED) — force-fail the owner's tasks'
    runs still `running` past 6h so a Tasks-list badge never shows a stale
    orphan as `running`. Owner-scoped indexed query
    (`list_running_runs_for_tasks`), conditional `update_run`, no per-run
    conversation read.
- Drop the now-unused `list_runs_by_status_all_workspaces` store method.

Net mechanism: (a) event hook = primary, instant terminal transition;
(b) lazy-on-read force-fail-stale on list + detail = the only orphan backstop.
No startup sweep, no periodic poll of any kind. Keeps the 6h
STALE_RUN_MAX_AGE_SECONDS invariant "every run eventually terminal".

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* refactor(scheduled tasks): drop dead ScheduledTaskRun.workspace_id field

The ``ScheduledTaskRun`` entity carried a ``workspace_id`` field solely so the
cross-workspace reconciler sweep could re-enter each run's ``workspace_scope``
before acting on it. That sweep is gone — completion is event-driven and the
lazy-on-read backstop both run inside a single ambient ``workspace_scope`` — so
the field has no reader. Its only consumer was the deleted ``_reconcile_run``.

Remove the field from the entity dataclass and drop the ``workspace_id=`` line
in ``_run_to_entity``. The DB column ``scheduled_task_runs.workspace_id`` (the
real tenant partition key) and its index are unchanged; the store still filters
every query on ``current_workspace_id()``.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* refactor(scheduled tasks): PR polish — comment fix, fired_at age basis, hook wiring test

Addresses three review findings on the FU-1 run-completion PR:

- Fix a stale finally-block comment in app.py: it still said the run reconciler
  is "a one-shot startup sweep (no periodic task to cancel)", but the startup
  sweep was removed — completion is event-driven + lazy-on-read, so there is no
  reconciler task at all. Comment now says only the per-job scheduler needs
  stopping. The scheduled_task_scheduler.stop() logic is unchanged.

- Measure the lazy-on-read stale window from fired_at (falling back to
  scheduled_at when a run never recorded a fire time), not scheduled_at. A run
  that fired late no longer gets a shortened effective window — the 6h clock
  starts when dispatch actually began. Locked by two unit tests: a run fired
  >6h ago is force-failed; a run scheduled >6h ago but fired recently is left
  alone.

- Add integration coverage for the primary completion mechanism at the
  _publish_status seam: drive the real _publish_status(conversation_id, "idle")
  / "failed" edge (the way the SSE relay does) and assert the scheduled_task_run
  transitions running -> succeeded / failed(+error_code) with finished_at set,
  through the hook + shared session_live_state executor (workspace_scope
  contract exercised, not bypassed). This locks the wiring so a future
  _publish_status refactor can't silently break scheduled-run completion.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
…mnigent-ai#3025)

Reintroduce the remote path split out of the initial dictation PR, now as
a registered engine rather than a special-cased branch.

- Register a `remote` engine (OMNIGENT_DICTATION_ENGINE=remote) that relays
  each take to a dictation worker over the same wire protocol the browser
  speaks. Selected purely by env var — OMNIGENT_DICTATION_REMOTE_URL points
  at the worker; no CLI integration, keeping the surface small for a niche
  deployment (weak main server + a beefier LAN box).
- Ship the standalone worker (python -m omnigent.server.dictation_worker):
  create_dictation_router served on its own, unauthenticated, LAN-only.
- Per-take fallback to the local sherpa engine (lazy) when the worker is
  unreachable and models are installed.
- Widen the web client's ready/stop timeouts to outlast the worker's
  cold-load budget.

websockets is already a core dependency, so no new package. The engine slots
into the registry with no changes to the route, protocol, or selection logic.

Co-authored-by: Isaac

Signed-off-by: kerry.chang <kerry.chang@your.hostname.com>
Co-authored-by: Ubuntu <kerry.chang@your.hostname.com>
…-ai#2589)

Skipping unresolvable function policies left an empty gate that allowed
every tool call. Install a deny sentinel instead so a misconfigured
policy cannot disappear silently.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
@scwf
scwf force-pushed the fix/windows-native-startup branch from 4e110e2 to 6fc409d Compare July 22, 2026 02:04
scwf pushed a commit that referenced this pull request Jul 22, 2026
…copy-at-spawn) (omnigent-ai#1041)

* feat(spawn): add file_ids to sys_session_send schema (omnigent-ai#900)

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* feat(server): add lineage-scoped file copy endpoint for subagent file passing (omnigent-ai#900)

Add POST /v1/sessions/{session_id}/resources/files:copy. The destination
(child) session copies parent-owned files authorized by spawn lineage:
the source must be the destination itself or an ancestor up the
parent_conversation_id chain. Each file is re-stored as a new
child-scoped row so the child reads its OWN copy — no cross-session read
grant is created, preserving the session-scoping invariant.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* feat(runner): forward file_ids from parent to subagent via copy-at-spawn (omnigent-ai#900)

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* test(e2e): file passing from parent agent to subagent (omnigent-ai#900)

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(omnigent-ai#900): harden file copy — strict-ancestor source, rollback partial copies, delete phantom child

Address codex review findings:
- Reject self as copy source; require a strict parent_conversation_id ancestor.
- Prefetch blobs during validation + roll back created rows/blobs on mid-batch
  storage failure, restoring true all-or-nothing semantics.
- Delete the freshly-created server child session when copy-at-spawn fails, so a
  failed spawn cannot leave a phantom child that poisons a same-(agent,title) retry.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* test(omnigent-ai#900): update sys_session_send schema assertions for new file_ids field

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(omnigent-ai#900): regenerate openapi.json for copy endpoint schema

Docstring reformatting (rst -> markdown) and the sessions ->
session_resources tag move drifted the committed spec from the
generator output, failing the openapi-drift gate. Regenerate to match.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(omnigent-ai#900): tear down child + defer resource events on copy-at-spawn failure

Two partial-failure bugs surfaced by cross-model (codex) review of the
copy-at-spawn path:

P1 (tool_dispatch): a named send that copied files successfully but then
failed to POST the child message only unregistered runner-local state —
it did not delete the freshly-created child like the copy-failure branch
does. That left a phantom child (poisoning a same-(agent,title) retry)
and orphaned the already-copied child-scoped file rows. Extract the
teardown into `_teardown_failed_child` and call it on every post-copy
failure path so they undo identically.

P2 (sessions copy endpoint): `files:copy` published and persisted
`session.resource.created` inside the per-file loop, before the batch
was known to succeed. A later write failure rolled back the file
rows/blobs but not those events, so clients saw phantom files. Defer all
resource events to a second loop that runs only after every write lands.

Tests: send-failure-after-copy deletes the child; mid-batch write
failure persists zero resource events and no orphan rows.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(omnigent-ai#900): bound copy-at-spawn — cap files/bytes + stream one at a time

Address PattaraS's blocking review finding on PR omnigent-ai#1041: copy_session_files
prefetched every source blob into memory before writing, so a send with many
or large file_ids was an unbounded memory spike on a shared server.

- Cap file count and summed StoredFile.bytes during metadata validation,
  BEFORE any blob is read, rejecting an over-limit request with 400 so a
  rejected request never buffers a blob.
- Limits are parameterized config knobs (copy_max_files / copy_max_total_bytes
  in server_config, defaulting to MAX_COPY_FILES=20 / MAX_COPY_TOTAL_BYTES=256
  MiB in content_resolver), overridable per deployment via the YAML config.
- Copy one file at a time (get -> create -> put) so peak memory is a single
  blob, not the whole batch; the existing rollback still gives all-or-nothing.
- Tighten the CopyFilesRequest/endpoint docstring to state the source must be
  a strict ancestor (self rejected).

Tests: over-count and over-total-bytes rejections assert 400 with ZERO blob
reads (artifact_store.get never called) and nothing copied; at-limit boundary
succeeds. Existing lineage/rollback/self-rejected coverage stays green.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(omnigent-ai#900): enrich copy response + CopyResult dataclass (PR omnigent-ai#1041 nits)

Two non-blocking nits from PattaraS's review of PR omnigent-ai#1041:

nit #1 — the copy response returned only an id mapping, so the runner
dispatch path did an extra metadata GET per file and guessed content-type
from the filename, even though the true content_type is preserved at copy
time. CopyFilesResponse.mapping now carries {new_id, filename, content_type}
per file (new CopiedFile model); _build_subagent_message_content reads the
type straight from the response — dropping N round-trips — and only falls
back to a filename guess when the source row had no recorded type.

nit #3 — _build_subagent_message_content returned a clunky
tuple[list, None] | tuple[None, str] (value, error) union. Replace it with a
small frozen CopyResult(content, error) dataclass; the single dispatch call
site branches on result.error.

Also regenerated openapi.json for the tightened CopyFilesRequest/endpoint
docstrings (strict-ancestor wording).

Tests: dispatch asserts the content type comes from the copy response with
ZERO per-file metadata GETs, plus a no-content_type→filename-fallback case;
endpoint tests assert the enriched {new_id, filename, content_type} mapping.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(omnigent-ai#900): probe artifact_store.exists during copy validation

Codex review of the cap-and-stream change flagged a regression: moving to
metadata-only validation dropped the original "missing source blob surfaces
before any child row is created" guarantee. A blob that failed mid-stream
(dangling row: metadata present, blob gone) would only surface after earlier
files were already written, leaning on best-effort rollback.

artifact_store.exists() is a cheap metadata probe (S3 HEAD / local stat / DB
row) — NOT a blob read — so calling it in the validation pass restores the
fail-before-any-write guarantee without reintroducing the batch prefetch or
spiking memory.

Test: a source whose blob was deleted (row intact) → 404 with nothing copied.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(files): address review feedback

---------

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
Co-authored-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
@github-actions github-actions Bot added the size/XL Pull request size: XL label Jul 22, 2026
TomeHirata and others added 3 commits July 22, 2026 11:52
…ent-ai#2998)

* perf(store): batch FTS inserts in append and fork_conversation

Each call to insert_fts issued a separate raw SQL INSERT into the
conversation_items_fts table, causing N+1 queries when appending or
forking conversations with many items.

Add insert_fts_bulk(session, rows) in omnigent/db/utils.py that issues
a single multi-row INSERT for any number of rows. Replace the per-item
insert_fts calls in append and fork_conversation with a single
insert_fts_bulk call after the loop. Keep insert_fts intact for
single-item callers.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(db): chunk insert_fts_bulk to avoid SQLite variable limit

Split rows into chunks of 300 (3 params × 300 = 900 binds) so a
single INSERT never exceeds SQLite's SQLITE_MAX_VARIABLE_NUMBER (999
on pre-3.32 builds). Without chunking, fork_conversation on a large
conversation raises OperationalError: too many SQL variables.

Also add the list[tuple[str, str, str]] annotation to fts_rows in
fork_conversation to match the append call site.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…omnigent-ai#2994)

Replace M individual session.get() PK lookups + M individual UPDATEs
with a single IN-clause query to fetch existing to_user grants, then
one bulk DELETE for duplicates and one bulk UPDATE for reassigns.

For M grants this reduces the query count from 1 + M + up to M = 1+2M
down to 3 queries regardless of M.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
…mnigent-ai#2974)

* fix(web): don't queue messages while only background work is running

A session with a running background job (background shell / still-running
sub-agent) settles into the `waiting` status: the turn already ended and the
server's turn gate is free to accept a new turn, but the frontend treated
`waiting` as busy and queued every new message client-side until full idle.

Two independent gates forced this:

- `shouldQueueSend` / `maybeFlushQueuedHead` treated `sessionStatus ===
  "waiting"` as busy, so sends queued and the queue wouldn't drain.
- The `session_status` handler grouped a `waiting` edge carrying a
  `response_id` (which the claude/cursor-native Stop hook always posts) with
  `running`, forcing local `status = "streaming"`, which never cleared while
  background work ran. The composer's "(queued)" placeholder and the send gate
  both key off local `status`, so this alone kept messages queued on native
  sessions.

Treat `waiting` as a turn-end edge everywhere it gates sends: drop it from the
busy checks and finalize the local send lifecycle like `idle`, while keeping
`sessionStatus = "waiting"` and `backgroundTaskCount` so the "Working…" spinner
and sidebar dot still reflect the background activity. A new message now starts
a fresh turn immediately, matching what the server already accepts.

This only affects sessions with background work running — a turn that ends with
no background work still settles on `idle` and behaves exactly as before.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* fix(web): treat waiting as turn-end on reconnect; add e2e coverage

Address the Polly review notes on the message-queueing fix and add the
e2e_ui coverage the required gate asks for.

- `reconnectStatusPatch`: a `waiting` snapshot is a turn-end edge, so it now
  finalizes the local send lifecycle like `idle` instead of reopening a
  streaming response. The server keeps `active_response_id` populated across
  `waiting` (it only pops on idle/failed), so grouping `waiting` with
  `running` re-opened "streaming" on a reload/reconnect and re-queued sends —
  the exact behavior the fix removes. Now covered for the reloaded-tab path,
  not just live SSE.

- The live-SSE mismatched-id `waiting` branch now finalizes a still-streaming
  bubble to `completed`, matching the matching-id path, so a stale bubble
  doesn't linger spinning with no edge left to close it.

- Add tests/e2e_ui/chat/test_send_while_background_task.py: publishes the
  native Stop-hook `waiting`+response_id edge live, then asserts the composer
  sends directly (idle placeholder, user bubble renders, no queued strip)
  instead of queueing behind the background task.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
@scwf
scwf force-pushed the fix/windows-native-startup branch from 6fc409d to e7914e1 Compare July 22, 2026 03:26
xq-yin and others added 3 commits July 22, 2026 10:34
…e UI (backend, flag-gated) (omnigent-ai#2912)

* feat(host): add install-harness tunnel frame pair + registry plumbing

Adds the HostInstallHarnessFrame / HostInstallHarnessResultFrame pair to
the host tunnel protocol, mirroring the existing HostCreateDirFrame
request/result pattern, plus the pending_installs future map on
HostConnection. This is the vocabulary the server and a connected host
use to negotiate a UI-driven harness install (later PRs add the host
handler, the route, and the frontend button).

Additive only: no frame is sent or received yet, so behavior is
unchanged. The result frame carries a freshly-recomputed readiness map
(configured_harnesses, reusing _optional_str_availability_map) so the UI
can flip the harness badge without waiting for a reconnect.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* refactor(onboarding): surface install failure reason from install_harness_cli

Extracts install_harness_cli_with_reason(key) -> tuple[bool, str | None]
alongside the existing install_harness_cli(key) -> bool, which becomes a
thin wrapper that discards the reason. Single implementation, no caller
churn: the four setup-wizard call sites keep their boolean contract
unchanged.

The reason is derived from the existing failure branches (manual-only
spec, missing installer, timeout, OS error, non-zero exit, post-install
binary-not-found) without capturing installer output — so omni setup's
live npm output UX is preserved. A later PR's UI-driven install returns
this reason to the user instead of a bare failure.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(host): install harness on request + resolve the install result

Adds the host daemon side of UI-driven install:
- _handle_install_harness in host/connect.py runs
  install_harness_cli_with_reason off the event loop, recomputes
  configured_harness_map(), and returns a HostInstallHarnessResultFrame
  carrying either the fresh readiness map or a failure reason.
- host_tunnel.py's receive loop resolves the pending_installs future.
- A shared allowlist/resolver (ui_installable_harnesses / ui_install_key)
  in onboarding/harness_install.py is the single source of truth for
  which harnesses are UI-installable (claude, codex, pi, opencode, qwen)
  and their install-spec keys.

Defence in depth: the handler re-checks ui_install_key, so a stray or
spoofed frame can never drive the installer for a non-allowlisted
harness (e.g. hermes, whose installer is a curl | bash). Inert until PR4
wires a sender: nothing emits HostInstallHarnessFrame yet.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(server): add UI harness-install route behind a default-off flag

Adds POST /v1/hosts/{host_id}/harnesses/{harness}/install: the server
endpoint the web UI's Install action calls. It validates in order —
feature flag (404 when off) -> allowlist (400) -> auth/require_user ->
owner (403) -> liveness (409) — then forwards a HostInstallHarnessFrame
over the tunnel via _proxy_install_harness and returns the host's
refreshed configured_harnesses map.

- Reuses the _proxy_create_dir request/future/wait_for template; the
  install timeout (330s) sits above install_harness_cli's 300s subprocess
  ceiling so the result is received before the server gives up.
- Concurrent installs of the same (host, harness) coalesce onto one
  in-flight task (conn.inflight_installs) so a double-click can't fire two
  non-race-safe global npm installs.
- Gated by OMNIGENT_HARNESS_INSTALL_ENABLED, surfaced to the SPA via
  GET /v1/info (harness_install_enabled), mirroring smart_routing_enabled.

Allowlist ordering (400 before 403) avoids leaking host ownership through
error codes. Ships dark: with the flag off the route is 404, so merging
this changes nothing in production.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(host): make UI install idempotent + widen the server wait

End-to-end testing against a real host surfaced two issues the stubbed
unit tests masked:

- The host ran `npm install -g` even when the harness CLI was already on
  PATH; npm re-resolves over the network and took >60s for an
  already-present binary, so a repeat Install click hung. _handle_install_harness
  now short-circuits on harness_cli_installed(key) and just returns fresh
  readiness (reusing the existing check) — sub-second on the happy path.
- The server's per-call wait (330s) sat only 30s above install_harness_cli's
  own 300s subprocess cap, so a genuine cold npm install could finish right
  as the server gave up — a "504 but actually installed" outcome. Widened
  to 420s (300s + 2min headroom for readiness recompute + tunnel latency).

Verified end-to-end: happy path 200 in 0.8s (already-installed fast-path),
a real cold opencode install completes route->tunnel->daemon->npm->readiness,
hermes rejected 400, codex reports needs-auth post-install.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* chore(openapi): regenerate spec for the harness-install route

CI's openapi-drift guard flagged openapi.json as out of sync after the
new POST /v1/hosts/{host_id}/harnesses/{harness}/install route. Regenerated
via scripts/dump_openapi.py so the committed spec matches the app.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* refactor(server): share the harness-install flag env-var name

Extract OMNIGENT_HARNESS_INSTALL_ENABLED into a single
HARNESS_INSTALL_ENABLED_ENV constant in hosts.py, read by both the
install route and the /v1/info flag in app.py, so the flag the UI sees
and the flag the route enforces can never drift on a typo. Also switch
the install-task scheduling from asyncio.ensure_future to the more
idiomatic asyncio.create_task.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(server): describe per-harness setup steps for the UI setup flow

Extends the harness-install backend so the web UI can render a "set up this
agent" checklist that mirrors omnigent setup, instead of a single Install
button.

- /v1/harnesses now carries an ordered setup_steps list per harness (install,
  then auth), derived from the existing HarnessInstallSpec so it can't drift
  from the real install/login commands. Claude/Codex/Pi/OpenCode/Qwen get a
  first-class two-step flow; other harnesses get a generic "run omnigent setup"
  step.
- The host readiness map now reports a two-step signal (binary-missing /
  needs-auth) for Claude and OpenCode too, matching Codex, so the UI can show
  install-done vs sign-in-done. Pi/Qwen stay binary-only (their credential
  isn't locally determinable).
- The launch gate (harness_is_configured) is unchanged and stays binary-only,
  so a not-signed-in harness is never blocked from launching.
- /v1/info advertises installable_harnesses (bare + native spellings) so the
  UI offers setup only where the install route will accept it.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(server): key harness setup steps by every spelling for the UI

The setup dialog looks up steps by the harness a session declares — often a
native wrapper (codex-native) or an installable id that isn't a picker row
(opencode/qwen), none of which appear in the harness catalog. Add
harness_setup_steps_by_spelling() and return it from GET /v1/harnesses as a
top-level setup_steps map so the dialog can resolve steps for whatever id it
holds, without adding non-pickable rows to the catalog.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(server): use host.user_id in the install route's owner check

The install route still compared host.owner, but the Host model's owner field
was renamed to user_id (identity-columns unification on main). An authenticated
install therefore 500'd with AttributeError. Switch to host.user_id (matching
every other host route) and add an owner-mismatch test that exercises the
ownership branch with a real user_id — the existing tests run unauthenticated,
so the comparison was never hit.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* docs(server): correct the setup-step "can't drift" comment

The auth-step commands (codex login, etc.) are display-only literals, not
derived from HarnessInstallSpec.login_args — only the install step's label is
derived. Reword the comment/docstring so they don't overstate the guarantee.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* Address review: family-keyed install coalescing + clearer naming

- Coalesce concurrent UI installs on the resolved install *family* key
  (ui_install_key) rather than the raw spelling, so codex + codex-native
  (both the openai npm package) share one in-flight install. Cleanup is
  tied to task completion via add_done_callback and every caller awaits
  under asyncio.shield, so a cancelled request can't clear the map out
  from under a follow-up and start a second concurrent `npm install -g`.
- Add an integration test that fires two overlapping same-family installs
  and asserts exactly one frame reaches the host.
- Rename install_harness_cli_with_reason -> try_install_harness_cli and
  return a HarnessInstallResult NamedTuple instead of a bare tuple.
- Trim the over-long install-handler docstring and UI-installable map
  comment to the essentials.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
* feat: import Qwen Kiro Pi and Kimi chats

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* 🐛 fix(import): Harden JSONL adapter contracts

- Expose stable Kiro and Kimi parser APIs for import reuse
- Hash overlong source IDs and bound Qwen locators safely

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
On Windows, `omnigent setup` could crash as soon as it reached the interactive
harness picker because the TTY menu path imported the POSIX-only termios/tty
modules. The user-visible failure was `ModuleNotFoundError: No module named
'termios'`, after the setup banner and preflight warning had already printed.

Route Windows setup menus through the existing numbered fallback instead of the
raw termios path, including the legacy wizard helpers and their back-navigation
behavior. Also remove the remaining POSIX os.getuid() assumptions from native
bridge temp-root setup so Windows installs do not fail while importing those
bridge modules.

Tested with the focused Windows startup regressions:
python -m pytest tests/onboarding/test_interactive.py tests/onboarding/test_wizard.py tests/test_claude_native_bridge.py::test_ensure_secure_dir_succeeds_without_getuid tests/test_qwen_native_bridge.py -q -k "not rejects_symlinked_ancestor"

Signed-off-by: scwf <wangfei_hello@126.com>
@scwf
scwf force-pushed the fix/windows-native-startup branch from e7914e1 to ba9c7dd Compare July 22, 2026 03:59
@scwf scwf closed this Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.